Autonomous Marketing Agent Architecture in Practice
Most writing about agentic marketing is speculation. This is the architecture of a system that runs on my own site every weekday: the task registry, the tool layer, the approval queue, the provider dispatch, and what it costs.
Table of contents
An autonomous marketing agent architecture is four parts. A task registry decides what runs and when. A run/task log split records what happened. A tool layer defines what the model is allowed to touch. A provider dispatch decides which model answers. In my system those are two TypeScript files, two Postgres tables, and one branch in a runner.
The agents never write directly to business tables. Every tool that would create a blog post, a client, or a follow-up task inserts a row into a pending_actions queue and stops there, waiting for me to approve it from the admin panel.
There is no agent framework anywhere in it. The whole system is the provider SDK, a Postgres table, an array of task objects, and a switch statement over tool names. A framework would have wrapped an abstraction around the two things that actually needed my attention: which tools exist, and where the write boundary sits.
Everything below is from the repository that serves this site: the schema, the tool shape, the cost arithmetic, and the parts that are currently wrong.
Most of it is a workflow, not an agent
Anthropic's Building effective agents draws the line I use: workflows are systems where models and tools are "orchestrated through predefined code paths", while agents direct their own process and tool use.
By that definition my outer loop is a workflow. A registry file names seven tasks, their prompts, and their cadence. Nothing decides at runtime whether the newsletter drafter should run today. A cron trigger and a day-of-week filter do.
The agentic part lives inside each task. Once a task starts, the model chooses which of sixteen tools to call, in what order, and when to stop, up to a hard ceiling of twelve tool-use round trips. That is the entire autonomy budget: twelve iterations, one task, one transcript.
| Layer | Where it lives | Decides | What breaks without it |
|---|---|---|---|
| Task registry | lib/agent-tasks.ts | Which task runs, on which days, with what prompt | Every run does whatever the model felt like that morning |
| Run and task log | agent_runs + agent_tasks tables | What ran, what it output, what it cost | You cannot answer "why did it do that" a week later |
| Tool layer | lib/agent-tools.ts | What the model can read and what it can only propose | The model gets a database connection |
| Provider dispatch | lib/agent-runner.ts | Which API and which loop shape handles the call | You are locked to one vendor's tool-calling schema |
| Approval queue | pending_actions table | Which proposals become real rows | Model errors reach production directly |
The task registry: what runs, and when
A task is a plain object. No class hierarchy, no plugin loader.
export type TaskDef = {
agentSlug: string
taskName: string
cadence: 'daily' | 'weekly'
weeklyOn?: number[] // 0=Sun..6=Sat, only read when cadence === 'weekly'
buildPrompt: (sharedContext: string) => string
}
The runner filters the registry once per invocation:
const dow = new Date().getDay()
const dueTasks = TASK_REGISTRY.filter(
(t) => t.cadence === 'daily' || (t.cadence === 'weekly' && t.weeklyOn?.includes(dow))
)
Six of the seven tasks are daily: content scout, client monitor, analytics digest, newsletter draft, social distribution, lead triage. The seventh, outbound prospect research, is Monday only, because it makes live web-search calls and I did not want that cost or that latency five times a week.
The trigger, and a timezone trap
The whole thing is one cron entry in vercel.json hitting /api/cron/daily-brief on 0 9 * * 1-5, with a bearer-token check against CRON_SECRET on the route.
Vercel's cron documentation is explicit that cron schedules are interpreted in UTC and nothing else, and that named expressions like MON are unsupported. So 0 9 is 09:00 UTC, which is 14:45 where I am in Nepal. I wrote "9am" and got mid-afternoon. Vercel's usage page adds that Hobby-plan crons are capped at once per day with per-hour precision, a window of plus or minus 59 minutes, so even the hour is a suggestion.
One task also fires on an event
One task also fires outside the schedule. The contact-form webhook calls the same runner with a single ad-hoc task, so an inbound lead gets triaged on submission instead of waiting until the next morning. Same runner, same tools, same queue. Only the trigger differs.
Two logging tables, on purpose
agent_runs gets one row per invocation. agent_tasks gets one row per task inside that run, with its own status, output, token counts, cost, and error column.
The split exists because of a failure I hit early: a rate-limit error inside the third task killed the whole batch, and the four tasks after it never ran. Now each task is wrapped individually. A thrown exception is caught, written to that task's error column as a failed row, and the loop continues. The run finishes; one row says what died.
That is also the audit trail. When a proposal in the queue looks strange, the agent_tasks row holds the full model output and the JSON action log that produced it. Building the admin views to read those rows was most of the work, and it followed the same pattern as the rest of my Supabase and Vercel admin panel.
The tool layer and where the boundary sits
Sixteen tools, split by direction.
| Direction | Count | Examples | What it touches |
|---|---|---|---|
| Read | 6 | read_clients, read_blog_posts, read_contact_submissions | Live tables, read-only |
| Propose | 6 | create_blog_post, create_lead_conversion, create_prospect_outreach | pending_actions only |
| Memory | 2 | read_agent_memory, write_agent_memory | agent_memory directly |
| External | 1 | web_search via Tavily | Outside network, gated on an active key |
| Transcript | 1 | log_recommendation | Nothing; the text lands in the task output |
Be precise about that third row, because the shorthand people repeat is wrong. write_agent_memory does write straight to agent_memory, no approval, on every run. The accurate claim is narrower: agents never write directly to business tables. Memory is the agent's own scratch space, and a wrong note about last week's content gap is cheap to delete.
Be equally precise about where the boundary lives. It sits in the tool surface, not in the credential. getDb() in lib/agent-tools.ts builds its Supabase client from SUPABASE_SERVICE_ROLE_KEY, falling back to the anon key — a full row-level-security-bypassing credential with read and write on every table, which is exactly what the read tools need to see live client and submission rows. Nothing at the key level stops a write to blog_posts. What stops it is that no tool function contains one: all six create_* handlers insert into pending_actions and touch nothing else. That control is only as strong as code review on the tool file, which is the honest way to describe it.
A tool definition is JSON schema in a TypeScript array:
{
name: 'create_blog_post',
description:
'Submit a new blog post draft for approval. The post is NOT created ' +
'immediately - it goes into a pending queue where Santosh reviews and ' +
'approves it before it is saved to the database.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string' },
slug: { type: 'string', description: 'lowercase letters and hyphens only' },
excerpt: { type: 'string' },
content: { type: 'string', description: 'Full post body in markdown' },
tags: { type: 'array', items: { type: 'string' } },
},
required: ['title', 'slug', 'excerpt', 'content'],
},
}
The description is load-bearing. Telling the model in the tool description that the write is deferred stopped it announcing "I have published your post" in its summaries.
The queue table
CREATE TABLE IF NOT EXISTS pending_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID REFERENCES agent_runs(id) ON DELETE SET NULL,
agent_slug TEXT NOT NULL,
action_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
review_note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS pending_actions_status_idx ON pending_actions(status);
CREATE INDEX IF NOT EXISTS pending_actions_created_at_idx ON pending_actions(created_at DESC);
ALTER TABLE pending_actions ENABLE ROW LEVEL SECURITY;
Two design choices worth stealing. payload is JSONB rather than a column per action type, so adding a seventh proposal shape needs no migration. And run_id uses ON DELETE SET NULL rather than cascade, so clearing old run logs cannot silently delete an unreviewed proposal.
The approve endpoint reads the row, switches on action_type, performs the real insert, and only then marks the row approved. The reasoning behind the whole boundary is in why my AI agents get an approval queue, not write access.
Provider dispatch
Three providers, two loops.
| Provider | Path | Why |
|---|---|---|
| Anthropic | Messages API with tools | Native tool-use loop, driven by stop_reason |
| Command Code | Same loop, baseURL swap | Its endpoint speaks the Messages schema for Claude models |
| Gemini | Separate loop | functionDeclarations, different request and response shape |
The Command Code case costs one line: same SDK, same loop, a different baseURL and a different key row. Gemini is not that. Its function-calling API uses a different request shape, a different response shape, and a different way of feeding tool results back, so forcing it through the Anthropic loop would have put a translation layer in the hot path. It got its own loop instead, about a hundred lines — runGeminiAgentTask spans 105 lines in lib/agent-runner.ts — and it reuses the same tool array directly because both providers accept JSON-schema-shaped parameters. The tool definitions are the one thing that survives the provider swap unchanged.
What it costs to run
Two costs, and only one of them is the API bill.
The invisible one is tool-definition overhead. Anthropic's pricing documentation states that a request carrying tools also carries a tool-use system prompt, listed at 497 tokens for Sonnet 4.6 with a tool choice of auto, on top of the tokens for the tool schemas themselves. Sixteen tool definitions are resent on every round trip in the loop. That is the price of a wide tool surface, paid up to twelve times per task.
Here is the arithmetic at the rates the provider currently publishes, with every input labelled as an assumption. The runner's own price map is a different object with different numbers, which is the first failure in the next section:
// Published list prices as of 3 September 2026 — deliberately NOT the map in
// the repo. lib/agent-runner.ts holds six rows and two of the Claude ones are
// stale; see "The price table drifts" below.
type Price = { in: number; out: number } // USD per million tokens
const PUBLISHED: Record<string, Price> = {
'claude-sonnet-4-6': { in: 3.00, out: 15.00 },
'claude-haiku-4-5': { in: 1.00, out: 5.00 },
}
function computeCost(model: string, tokensIn: number, tokensOut: number): number {
const p = PUBLISHED[model] ?? { in: 3.00, out: 15.00 }
return (tokensIn / 1_000_000) * p.in + (tokensOut / 1_000_000) * p.out
}
// ASSUMED, not measured: one task at ~9,000 input tokens (system prompt +
// business context + 16 tool schemas + memory, across three round trips)
// and ~1,800 output tokens.
const perTask = computeCost('claude-sonnet-4-6', 9000, 1800)
const perWeekday = perTask * 6 // six daily tasks in the registry
const perMonth = perWeekday * 22 // ~22 weekdays
console.log(perTask.toFixed(4), perWeekday.toFixed(4), perMonth.toFixed(2))
// 0.0540 0.3240 7.13
console.log(computeCost('claude-haiku-4-5', 9000, 1800).toFixed(4))
// 0.0180 - the same task on the cheaper model
Under those assumptions the whole system costs about seven dollars a month, and the model choice moves it by a factor of three. For the per-task version of this arithmetic applied to ordinary marketing work, I broke it down in what an LLM actually costs per marketing task.
What it gets wrong
Five honest failures, in rough order of how much they annoy me.
The price table drifts, so logged cost is an estimate
The runner holds a hardcoded price map and multiplies token counts by it. Checking it against Anthropic's pricing page while writing this post, two of the three Claude rows turned out stale: my table bills Opus 4.8 at 15 and 75 dollars per million tokens against a published 5 and 25, and Haiku 4.5 at 0.80 and 4.00 against a published 1.00 and 5.00. The Gemini rows name 2.0 Flash and 1.5 models that Google's current pricing page no longer lists at all. Sonnet 4.6 happens to match. So agent_tasks.cost_usd is an internal estimate that rots quietly between provider price changes, and I should be reading usage from the provider instead of recomputing it.
Approval on three action types means "seen", not "sent"
Approving a blog post inserts a real row into blog_posts. Approving a lead conversion creates a client. Approving a social post, a newsletter draft, or a prospect outreach only marks the queue row approved, because no social API, no mail connector, and no verified contact email exists behind them. Approval there means I read it and accepted it. The sending is still my hands.
The content agent proposed volume into a corpus that needed cutting
The daily content scout is instructed to find gaps and draft posts. It did that faithfully. Meanwhile Search Console for the 85 days to 3 September 2026 showed 22 clicks against 4,553 impressions across roughly 390 indexed URLs, with 111 of 182 ranking pages earning five or fewer impressions in the quarter. More drafts was the wrong output. I ended up cutting the published count from 267 to 103, a 61% cut and the opposite of what the agent kept proposing. No agent asked to delete anything, because I never gave one a tool that could. If your archive is in that shape, the fix is an audit before any agent starts adding to it, which is what a content audit is for. The full teardown of my numbers is in 389 pages, 22 clicks.
The self-imposed timeout is the real constraint
The cron route sets maxDuration = 60. Vercel's duration documentation puts the current default at 300 seconds, so that ceiling is self-imposed, and it is why prospect research runs weekly. Six sequential tasks, each capable of twelve model round trips, do not fit in sixty seconds once one of them starts making web-search calls. The correct fix is a queue with one task per invocation. I have not built it, because the current shape has not failed yet.
A queue only works if someone drains it
The approval boundary moves the risk from the database to my attention. If proposals sit unreviewed for two weeks, the system has produced a backlog and an API bill and nothing else. That is the same discipline problem I ran into shipping four full-stack platforms in 25 days: the build is fast and the reviewing is not, and reviewing is the one part I cannot hand back to the thing under review.
FAQ
What is an autonomous marketing agent architecture? A scheduler that triggers model runs, a registry defining which tasks run on which days, a tool layer describing what the model may read and write, per-run and per-task logging, and an approval boundary between model output and production data. In my build that is two TypeScript files, four tables, and one cron entry.
Should marketing agents write directly to the database?
Mine do not, for business data. Read tools hit live tables; write tools insert proposals into a pending_actions queue with a status of pending, approved, or rejected. The exception is the agent's own memory table, which it writes without approval, because a wrong note there costs nothing to delete.
How much does it cost to run AI agents for marketing? For my system, under assumptions stated in full above, roughly seven dollars a month for six daily tasks on Sonnet 4.6, and about a third of that on Haiku. The cost that surprises people is tool-definition overhead, resent on every round trip of every task.
How do I stop an agent inventing a customer email address? Do not give it a tool that can accept one. My outbound tool has no email field at all. It takes a company, a role, and a drafted message; I source and verify the address myself. Deleting the field is a stronger control than tightening the database key, because the key the agent runs on is a service-role key and tightens nothing.
Is your content system quietly producing more than it should? An audit tells you which pages earn attention before any agent starts adding to the pile. Get a content audit or get in touch.
Get the AI Automation Playbook
The real architecture behind a 6-agent AI content team — what it saves, what it gets wrong, and the propose-then-approve pattern that makes it safe to trust.
Browse all free guides →Want to implement this with guidance?
Santosh helps founders turn insights like this into real systems.
External Resources
Further Reading & Tools
Make.com Blog
No-code automation tutorials, workflow templates, and integration guides
Zapier Learn
Automation best practices, productivity guides, and tool integrations
OpenAI Platform
API documentation for building AI-powered automation workflows
n8n Blog
Open-source workflow automation guides and integration examples