Back in July I wrote about what it takes to wire an AI agent into social platforms: six OAuth flows, three-step media uploads, tokens expiring on their own private schedules. The conclusion was to hide all of it behind a single tool call and stop looking at it.
Two months on, that part is done. Our MCP server answers over OAuth now, with proper metadata at /.well-known/oauth-authorization-server, PKCE, and dynamic client registration, so connecting an editor no longer involves typing a key into a config file. Sixteen tools, one endpoint. The plumbing works.
What I got wrong was assuming the plumbing was the risky part.
My job is getting our product into people's hands, which means I use it the way I hope other people will: I ask an agent to publish and go back to what I was doing. Living with that for a couple of months taught me that once an agent has write access, failures stop announcing themselves.
An identifier that looks exactly like an identifier
A language model produces plausible-looking strings. That is the entire skill. Ask it to post to LinkedIn and it can hand the API a value with the right prefix, the right length, the right shape, and the wrong account.
Nothing about that request is malformed. There is no error to catch. The API was asked to do something specific and it did it.
So the first instruction our server gives any client is not a description of what it does. It's a rule: call list_connections first, copy each platformId verbatim, never invent one. Before the tool list, before the examples, before anything explaining what the product is for.
Writing documentation for a reader who will confidently improvise is a genuinely different job from writing it for a human who will get bored and skim.
"Tomorrow at 9am" is a timezone question
The API takes ISO 8601 in UTC and nothing else. So when you say "tomorrow at 9am," something has to decide which 9am you meant, and that something is the model.
It's right most of the time. When it isn't, nothing surfaces at the call. The response is a normal success, the post sits in the queue with a perfectly valid timestamp, and you find out at 4am from the post itself.
I now read the scheduled time back in the confirmation. Not because the model is bad at arithmetic, but because a wrong answer here is indistinguishable from a right one until it's too late to matter.
Accepted now, dead later
Instagram, TikTok and YouTube won't publish without media. Nothing stops an agent from scheduling a text-only Instagram post: it validates, it enters the queue, it sits there looking healthy for a day, and it dies at publish time.
Queue membership is not a promise. It's the kind of distinction you only learn by getting burned, because until then the failure is completely invisible.
The annotations nobody looks at
MCP lets a server tag each tool with hints about what it does: readOnlyHint, destructiveHint. Ours are filled in. Sixteen tools, six of them flagged destructive: deleting posts, deleting media, removing a LinkedIn comment or reaction.
{
"name": "delete_post",
"annotations": { "readOnlyHint": false, "destructiveHint": true }
}
They're advisory. A client can ignore them entirely, and plenty do. But they cost almost nothing to add, and they're the only way a server can tell a client "this one deserves a confirmation dialog" without inventing a private protocol. If you run an MCP server and haven't filled them in, that's twenty minutes of work that lets every well-behaved client protect your users for you.
A fake platform to post into
The fix I like most is the least clever one:
{ "content": "test", "platforms": ["publora-playground"] }
It accepts the post, validates it against the real rules, returns a normal response, and throws it away. Nothing reaches a real network.
It exists because there was previously no honest way to answer "is this connected and working?" Every genuine end-to-end test involved putting something real on someone's real timeline, which is a fine way to test at 2am and an awful one at any other hour. Now the whole round trip is testable without an audience.
Every integration that writes somewhere public should have one of these, and most don't.
What I'd tell July
I'm biased about the product, so here's the part that isn't about it.
When you give an agent write access to anything outward-facing, the failure worth designing against is not the 500. Exceptions land in logs and somebody eventually reads them. The dangerous one is the call that succeeds and quietly does the wrong thing: right shape, wrong target, no error anywhere in the chain.
The July version of this was "hide the complexity behind one tool call." I still think that's right. I'd just add the second half now: and make the tool call hard to get subtly wrong, because subtly wrong is the only kind of wrong that gets published.
Posting from the terminal saved me a context switch. The guardrails are what made me willing to leave it running while I did something else.
I drafted this with Claude and then checked every claim against the live server before publishing. The playground response, the tool annotations, and the OAuth metadata are all things I re-ran rather than remembered.
If you run an agent with write access to production, where's your line: a dry-run target, tool annotations, or a human confirming every call?
Top comments (15)
The account check is the guardrail I wish more agent demos showed. Once a browser profile has more than one logged-in surface, a green "posted" result is not enough. I like keeping the publish step behind a tiny identity assertion, then logging the target account next to the action id. Boring, but it turns a very awkward failure into a caught precondition.
Good angle — one green "posted" can hide the wrong tab out of several logged in. And "log the target account next to the action id" keeps coming up here, from people who got burned once. Boring and reliable. Taking it.
Treat the expected account as a separate invariant, not as metadata carried by the agent. Immediately before a write, resolve the live connection and reject unless its username or account ID matches the configured target; after the write, verify the returned object owner, not just its existence. That turns a valid-but-wrong platformId into a failed precondition instead of a successful post noticed later.
Thanks — all of it is spot on :) You're a sharp crowd here. I'll let a few more comments land and then do one proper pass to fix everything at once.
For me the line is simple, agent may propose but never name the target itself. Echo back is good, but only if something other than same agent reads it, otherwise it just agrees with itself. In infra I had a crash that looked like database failover and every signal agreed, because all of them measured the same wrong thing. Success that nobody independent checked is not success, it is only a report.
Ha, you got me. Now it's stuck in my head and Claude's, and we'll be chewing on it for a while. That failover story is the part that'll haunt me — everything green because it all measured the same wrong thing. "Success nobody independent checked is only a report" is going on a sticky note. Thanks for the fast, honest read.
The playground target is the best idea in this post and I think it deserves more than the paragraph you gave it, because it is doing something the rest of the guardrails cannot.
Everything else in your list catches a wrong action. The playground is the only one that lets you verify a right one without consequences - and proving success is structurally harder than proving failure, because the evidence for success is the side effect you were trying to avoid. Every other domain solves this with a staging environment and then quietly stops keeping it in sync. A fake target inside the real code path, validated by the real rules, is much cheaper and much harder to let rot.
Though "harder to let rot" is not "cannot rot", and that is the one question I would put to it: what keeps the playground's validation identical to the real one? If a platform adds a constraint next quarter - a length limit, a new required field - and the playground branch does not learn it, then green in the playground stops meaning green in production, and it fails in the direction nobody investigates. Is the validation literally the same code path with the delivery step swapped, or is there a second implementation that has to be kept honest by hand? The first is a mechanism; the second is a promise with a deadline.
On the identifier problem - I think your first instruction is fighting the right battle with the wrong weapon.
"Call list_connections first, copy each platformId verbatim, never invent one" is a rule, and a model that can produce a plausible-looking string can also produce a plausible-looking reason to skip a rule. What would end the class rather than discourage it is making the identifier unforgeable: a server-issued opaque handle, short-lived, that only exists because list_connections returned it. Then "never invent one" stops being a request and becomes arithmetic - an invented value is not wrong, it simply does not resolve.
I hit the same shape from a different angle this week in a memory layer I work on. A read path could not enforce access scoping because it never received the caller identity - it took a topic string and nothing else. It was not a forgotten check; it was a function with no way to know who was asking. What I took from it: the boundary has to live in the signature, not in the discipline. A parameter that is absent cannot be forgotten later, and an identifier that cannot be constructed cannot be hallucinated.
@artyomsv's line in this thread is the one I would put above all of it - "success that nobody independent checked is not success, it is only a report" - and I have a fresh case that shows it does not need agents or a failover to bite.
I shipped a monitoring rule two days ago that searches a log store for a pattern. The log store writes every query it runs into its own log. The log shipper collects that log. So the rule found the record of itself asking and counted it as evidence. Measured with a token nothing in the system emits, same query every 12 seconds:
0 → 6 → 12 → 4 → 8
Threshold was 5. Empty world. Every signal agreed because every signal was the same signal wearing different timestamps. Which is exactly his failover story with the database removed - the property is not "agents lie", it is "the observer and the observed share a channel", and that can happen to plain infrastructure just as easily.
To answer your closing question directly: my line is the dry-run target, but with one condition attached that I did not have until this week. A playground only counts if something proves it still fails - send it a post that must be rejected, on a schedule, and count the rejections. Otherwise you have built a target that returns success no matter what, which is the failure mode you wrote the whole article about, relocated into the safety mechanism.
We hit the same class of bug from the other side of the boundary: our browser automation trusted a cached device→profile mapping, the mapping went stale, and the post published fine — under the wrong identity. Reading the logged-in name off the live page before any write turned out to be your list_connections rule wearing different clothes. The fake-platform E2E is the piece we do not have; we still test against the real service, which is how that one reached production.
"Your list_connections rule wearing different clothes" — that's it exactly. It shows up identically in browser automation and in an MCP server, which tells you the failure is in the shape of the problem, not any one stack. Cached mapping, verbatim-carried identifier, same bug underneath.
The fake-platform target came out of exactly the corner you're describing. Every honest end-to-end test meant putting something real on a real timeline, so testing at any civilized hour was a gamble — you test less, and the untested path is the one that reaches production. publora-playground runs the whole round trip against the real validation and throws the result away, so "does this work end to end" stops depending on having a spare real account to burn. It won't catch a stale mapping that only lives in the live browser session, though. That's the reminder in your case: a dry-run target and reading identity off the live surface are two different guardrails, and you need both.
The "plausible-looking identifier" failure mode is exactly what makes agent write-access scary — no error to catch because the API did precisely what it was told. Your
list_connections-first rule is the right call.I hit a sibling of this running scheduled publishing jobs: a POST timed out, my retry fired, and the platform rejected it as a duplicate — the first request had actually landed. Now my retry loop always re-reads state before retrying any write, never assumes the failure I saw is the failure that happened. Your read-back-the-scheduled-time habit is the same instinct: verify the world, not the response code.
Curious how far you take it — do you also make the agent confirm destructive/irreversible calls with a dry-run or preview step, or is verbatim-copy-from-listing enough guardrail in practice?
That duplicate-on-retry story is the cleanest "verify the world, not the response code" I've seen — the failure you saw wasn't the failure that happened. Re-reading state before each retry is the same instinct as reading the schedule back.
On your question, the honest answer is it's layered, not a hard gate. There's a dry-run target (publora-playground) that validates against the real rules and discards the result, so connection checks never touch a real timeline. Destructive tools carry a destructiveHint — the server saying "confirm this one" — but it's advisory, and a client can ignore it. What there isn't is a forced preview on the destructive call itself: delete-post executes immediately, no status restriction, removing the whole post group and its media in one transaction. What I document instead is get-before-delete — fetch the post, show the caller what's about to go, then delete — but that's a convention, not something the API enforces (docs.publora.com/endpoints/delete-...).
So verbatim-copy handles the wrong-target case at the input, get-before-delete gives the caller a look before the write, and neither one is a real gate on irreversible actions. The edge that keeps me honest: deleting an already-published post removes it from Publora, but not from the platforms it already reached. Nothing forces a confirm on that, and it can't be undone. Still an open gap.
The interesting failure here isn't bad OAuth or a failed API call it’s semantic authorization. A platform can validate that platformId is structurally valid and still accept an action against the wrong account. That’s a much harder class of failure because the system reports success.
I’d add one invariant to the design: the target identity should be resolved and echoed back by the server immediately before any write, rather than trusting an identifier carried through the agent’s context. For scheduled actions, the same principle applies to time and capabilities. The agent can propose the action, but the server should resolve the target, normalize the schedule, validate the platform-specific constraints, and only then create the job. In agentic systems, “request accepted” is nowhere near the same guarantee as “the intended side effect is what will execute.”
Exactly — semantic authorization, that's the name for it. And it opens the real question: what do you let the agent decide, and what do you double-check? Because the danger isn't a crash. It's the run that comes back clean, no errors anywhere, and the user still got something they didn't ask for. That's the part worth designing around.
This is a great example of why “200 OK” isn't enough for agent actions. The response needs to verify both the result and the target identity. A successful write to the wrong account is still a failure.
Exactly — "successful write to the wrong account is still a failure" is the whole post in one line. The response code tells you the call ran, not that it did what you meant. Thanks for reading.