← Back to Blog
AI & MarketingAI AgentsAutomationAI

Why My Marketing Agent Proposes Instead of Publishing

My autonomous agents cannot write to a single business table. Every action they want to take lands in a pending_actions queue and waits for me to click approve. Here is the schema, the state machine, and the honest cost.

SPSantosh Paudel· September 6, 2026· 13 min read
Table of contents

Human-in-the-loop approval for an AI agent means the agent never performs the side-effecting write itself. It writes a description of the write into a queue table, and a person promotes that row to a real insert. On my site the queue is one Postgres table called pending_actions, the agent's tools return "proposal queued" instead of "done", and an admin route does the actual insert when I approve. An agent that can publish is a liability. An agent that can only propose is a research assistant with a commit button, and the commit button is mine.

The one rule: agents propose, humans commit

I run an autonomous agent system on this site. Vercel Cron triggers it, it reads my CRM, my blog table, my contact submissions and my newsletter list, and it decides what should happen next. I wrote about how the runner and the tool loop are put together in the architecture post. The design constraint underneath all of it is one sentence:

Agents never write directly to business tables.

Not "usually". Not "unless confident". Never. Every tool whose name starts with create_create_blog_post, create_client_task, create_lead_conversion, create_prospect_outreach, create_social_post, create_newsletter_draft — inserts into pending_actions and returns a message telling the model its proposal is queued for review.

The restriction lives in the tool surface, not in the credentials. The agent's Supabase client is built with the service-role key, which bypasses row-level security and could write to any table on the project. What stops it is that no tool function contains an insert into a business table. Every create_* tool inserts into pending_actions and nothing else. That is why the honest control is removing the function, not restricting the key: a key you can quietly widen later is not a boundary, a missing function is.

The carve-out I have to be honest about

There is exactly one write that bypasses the gate: write_agent_memory writes straight to the agent_memory table. That is deliberate. Memory is how a scheduled agent avoids re-proposing the same thing every six hours, and gating it would mean the agent forgets everything between runs unless I babysit it. But it means "my agents cannot write to the database" would be a false claim. The accurate one is narrower: agents never write directly to business tables — the ones where a bad row costs me a client, a subscriber, or a live URL.

Most "safe agent" claims collapse under one grep. Mine collapses under one too, which is why I am telling you where.

The queue is one table

No framework. No orchestration layer. One table with a status column and a JSONB payload:

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);

That is the whole safety mechanism. The CHECK constraint is the state machine. Postgres refuses any status outside those three strings, so a bug in my API route cannot invent a fourth state. run_id links the proposal back to the agent run that produced it, so when a proposal looks insane I can read the transcript that led to it.

The review screen is one read off that table. The endpoint pulls the last fifty rows in one shot and the pending/reviewed split happens in the UI, on a filter toggle:

SELECT id, agent_slug, action_type,
       payload ->> 'title' AS title,
       status, created_at
FROM pending_actions
ORDER BY created_at DESC
LIMIT 50;

Filtering client-side is fine while the queue is smaller than the page size. Past that the WHERE status = 'pending' belongs in the query, which is what the status index above is for.

Why a table and not a framework

Cloudflare's agent docs describe human-in-the-loop patterns as adding approval at different layers of an agent, including marking connector methods requiresApproval: true so execution pauses before a tool fires. LangGraph does it by pausing the graph: the interrupt() docs explain that when an interrupt triggers, the framework saves graph state through its persistence layer and waits indefinitely until you resume, with a thread_id acting as your cursor back into the checkpoint.

Both solve a harder problem than mine. They keep a live execution suspended across the approval window. I did not need that. My agent runs are short, they end, and the proposal outlives the run as a database row. A row in Postgres is already durable, already queryable, and already visible in the admin panel I had built for the CRM anyway.

Pick the pause-the-graph pattern when the agent must continue from the approval with its context intact. Pick the queue when the approved action is self-contained enough to re-execute from its payload alone.

What approval actually does

This is where most write-ups get vague. "The human approves and the action executes" — executes how? In my system, approving is a POST to an admin route that reads the payload and performs the real insert, per action type. They are not all the same:

Action typeWhat approve doesExecutes a write?
create_blog_postInserts into blog_posts with published: true, computes reading time server-sideYes
create_client_taskInserts into client_tasks against the proposed client_idYes
create_lead_conversionInserts a clients row with status lead, then back-links the originating contact_submissions rowYes
create_prospect_outreachMarks the research accepted. I source and verify the real email, then add the client manuallyNo — mark-only
create_social_postMarks the draft accepted. No social API is connectedNo — mark-only
create_newsletter_draftMarks the draft accepted. No sending connector is wiredNo — mark-only

Half of the action types do not execute anything on approve.

Why three of them are mark-only

Three of those six action types deliberately do nothing on approve except flip the status and record the timestamp. The reason is different for each, and none of them is "I ran out of time".

create_prospect_outreach cannot execute because clients.email is NOT NULL and the agent has never verified an email address in its life. It found a company, it drafted an angle, it guessed a contact. Promoting a guess into my CRM would poison the table I use to decide who to follow up with. Approval there means "the research is good", not "this fired".

create_social_post and create_newsletter_draft cannot execute because there is no connector on the other end. The honest version of that is a queue row that says a draft is ready, which I copy out. The dishonest version is wiring a send button now so the demo looks complete, and finding the failure mode when an agent mails subscribers something it hallucinated. A mark-only approval is a stated boundary in the code.

The state machine

FromEventToSide effect
(none)Agent calls a create_* toolpendingRow inserted with payload; tool returns "queued for review" to the model
pendingI click ApproveapprovedThe real insert runs first, then reviewed_at is stamped
pendingI click Reject with a noterejectedNothing is written to any business table; review_note stores why
approved / rejectedApprove again(no change)Route returns 400: "Action is already approved"
approved / rejectedReject again(no change)Update is scoped to status = 'pending', so it touches zero rows, but the route still returns 200
pendingNobody looks at itpendingStays forever; there is no TTL and no auto-approve

The terminal-state guard is the important row. Before doing anything the approve route re-reads the action and refuses if status !== 'pending', so a double-clicked button or a replayed request cannot create the same blog post twice. Reject is guarded differently: its UPDATE carries .eq('status', 'pending'), so re-rejecting a terminal row changes nothing. It reports 200 anyway, because I never checked the affected row count. Harmless here, since reject writes nothing, but it is the kind of silent success that trains you to trust a response body that means nothing.

The gap I have not closed

There is still a window. The route inserts the blog post, then updates the status to approved. If the insert succeeds and the status update fails — connection dropped, deploy mid-request — the row stays pending and a second approval creates a duplicate post. The correct fix is to claim the row first with a conditional update and only execute if the claim wins:

UPDATE pending_actions
SET status = 'approved', reviewed_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, action_type, payload;

If that returns zero rows, someone else already claimed it and you do nothing. If it returns one row, you own the execution. The reject route already claims its row this way. The approve route, the one where the claim actually matters, does not. It is four lines and I have not shipped it, because the queue currently has one reviewer and he does not double-click. That is a real reason and a bad one, and I am writing it down so it stops being invisible.

What happens when you skip the gate

An agent that can publish will bury you

I have first-hand evidence of what unreviewed publishing does to a site, and it did not even involve an agent. This domain carried 279 seeded posts. About 150 of them averaged 380 words with no table, no code, no internal link and no image. Over 85 days the whole domain produced 4,553 impressions and 22 clicks — a 0.48% CTR — and 111 of the 182 ranking pages earned five or fewer impressions in the entire quarter. I cut the published count from 267 to 103.

Now imagine a create_blog_post tool with direct table access and a cron trigger. It would have added to that pile every six hours, forever, and every one of those pages would have been indexed, linked from the sitemap, and competing with the posts that actually rank. The full teardown of those numbers is its own post; the scoring model I used to decide what died is in the pruning post.

If you are already sitting on a corpus that grew faster than anyone reviewed it, the gate is the second problem. The first is knowing which existing pages are load-bearing, which is what a content audit is for, and it is the input that tells an approval queue what "good" even looks like.

You still own the output

Skipping the gate does not move liability. In Moffatt v. Air Canada, 2024 BCCRT 149, the British Columbia Civil Resolution Tribunal held the airline responsible for a bereavement-fare policy its chatbot invented, and called Air Canada's argument that the chatbot was a separate legal entity "remarkable" — noting that a chatbot is still just part of Air Canada's website. McCarthy Tétrault's write-up covers the reasoning.

Whatever it published, you published.

The cost, stated plainly

A human gate means the agent's throughput is bounded by my attention. That is the entire trade and there is no clever way around it.

If I review proposals twice a week and clear ten each time, twenty is the ceiling, no matter how many the agent generates. Assume the runs produce thirty a week: ten of them expire into a backlog I will never read, and the queue becomes a place proposals go to die. That is a labelled worked example with numbers I chose, not a measurement, but the shape is right and I have already felt it. The mitigations I actually use are boring: fewer scheduled runs, tighter tool descriptions so the agent proposes less garbage, and rejecting with a note so the reason is recoverable later.

There is a second cost people skip. A queue you approve without reading is worse than no queue, because it manufactures the paperwork of oversight without the oversight. If you find yourself clicking approve on titles alone, the gate has already failed and you should either read the payloads or turn the agent off.

The other side of the argument — why approval beats write access even when the model is good — is its own post.

FAQ

What does human-in-the-loop mean for an AI agent?

The agent stops before any action with a side effect and waits for a person to approve, edit, or reject it. There are two common implementations: pause the running agent and resume it after approval (LangGraph's interrupt()), or have the agent write a proposal to a durable queue and end its run, with a separate path executing approved proposals later. I use the queue.

How do you stop an AI agent from writing to your database?

Not with credentials, in most real setups. Mine reads its CRM and its blog table, so it holds a service-role key with read and write on everything. Scoping that key down to "insert into pending_actions only" would break the reads the agent exists to do. The control is the tool surface instead: of the sixteen tools the agent can call, none contains an insert into a business table, and every create_* tool writes a proposal row and nothing else. Prompting a model not to do something is not a control. Removing the function that does it is.

Should approving an agent action always execute it?

No. Some approvals should be mark-only: a record that you accepted the reasoning, with the execution still done by hand. That is correct whenever the payload is missing a field you must verify (an email address), or the downstream connector does not exist yet. Making approve mean two different things is fine as long as the UI says which one you are getting.

Is a human approval queue worth the slowdown?

If the action is reversible and cheap, probably not — gate the expensive ones. If it publishes a public URL, contacts a person, or writes to a table you make decisions from, yes. The slowdown is the product.

Building an agent that touches your content or your CRM? Start by knowing which pages and records are actually worth protecting, because that is what the gate is defending. Get a content audit or get in touch.

Free resource

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.

No spam. Unsubscribe anytime.

Browse all free guides →

Want to implement this with guidance?

Santosh helps founders turn insights like this into real systems.

AI Agent Systems

External Resources

Further Reading & Tools

Related Posts

01
14 min
AutomationAI
TodayAI Workflow

Wiring GA4, Search Console and a CRM to MCP

Model Context Protocol lets a model query your analytics stack directly. What that actually buys you, a real tool definition, and the three places it breaks.

Read article
02
15 min
AI AgentsAutomation
TodayAI & Marketing

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.

Read article
03
13 min
AI MarketingAI
TodayAI & Marketing

What 15 Marketing Tasks Cost in LLM Tokens

I priced 15 named marketing jobs against the live rate cards for Claude, GPT and Gemini. The whole set runs once for between $0.07 and $3.32. Editing the output costs about $317. That ratio is the post.

Read article