Sponsored Content

DEV Community

Cover image for The agent posted successfully. To the wrong account.
Eugeniya Ivanova
Eugeniya Ivanova

Posted on Originally published at publora.com

The agent posted successfully. To the wrong account.

Real-world dangers of autonomous social publishing

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 }
}
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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 (34)

Collapse
 
reidmarlow profile image
Reid Marlow

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

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.

Collapse
 
heinrichneb profile image
Heinrich Neb

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

This is the best comment the piece has gotten, and the playground question is the one I want to answer precisely rather than quickly — so I'm going to check the exact implementation with the person who built that path before I claim either "same code path" or "second implementation." You've named the thing that decides whether it's a mechanism or a promise with a deadline, and I'd rather come back with the real answer than guess at my own system in public. That itself is a small version of the whole post.

But your closing condition mostly dissolves the question, and I think it's the strongest idea in the thread. A playground that only ever returns success is the article's own failure mode relocated into the safety mechanism — I hadn't seen it stated that cleanly, and it's exactly right. Sending it a post that must be rejected, on a schedule, and counting the rejections, is what keeps "green in playground" tied to "green in production" whether or not the validation is literally shared code. If the reject-test goes quiet, the sync broke — and now the drift fails in the direction someone actually investigates, instead of the silent one. That's the missing half of the idea as I wrote it. It goes in.

On the unforgeable handle — you're right that a rule is a weapon a plausible-string generator can also argue its way around, and a server-issued opaque handle turns "never invent one" from a request into arithmetic. Your memory-layer framing is the part I'll keep: the boundary has to live in the signature, not the discipline. A parameter that can't be constructed can't be hallucinated, and a check that can't be reached can't be skipped. Verbatim-copy was me enforcing in prose what should be unrepresentable by construction. I don't have the opaque-handle version built — it's a real design change, not a prompt tweak — but it's the right direction and I'd rather aim at ending the class than discouraging it.

And @artyomsv's line deserves the top spot you gave it. Your log-shipper story is the cleanest proof it isn't about agents at all — the observer and the observed sharing a channel is just as available to plain infrastructure. A rule counting the record of its own query as evidence is going to live in my head for a while.

Collapse
 
heinrichneb profile image
Heinrich Neb

"I'd rather come back with the real answer than guess at my own system in public" is the article practicing itself, and it's worth naming: not guessing about your own system is the independent check, applied to the author. Take your time with the playground answer - the thread will keep.

Since the planted reject is going in, three operational details from running the same pattern elsewhere, offered so you can skip the rakes we stepped on. First: the reject must come from the real validation through the real door - not from a test-mode flag that short-circuits into "rejected." The moment the drill has its own code path, you're back to two implementations, just smaller. Second: rotate the reason. A fixed always-too-long post proves the length rule still fires and nothing else; when the platform adds the required field next quarter, your scheduled reject stays green while the new rule drifts unguarded. A small cycle of violations - one per rule you care about - turns the drill from "the validator is alive" into "the validator still knows its rules." Third, and this one is cheap: the rejection counter needs an age alarm. A scheduled test that quietly stops being scheduled rots exactly like the thing it guards, and "last planted reject older than the schedule interval" is where the recursion finally terminates - checking a timestamp's age is stateless, can't be blind about an empty set, and is boring enough to trust. Guard, drill for the guard, age alarm for the drill. Three layers, and only the first one is clever.

On the opaque handle being a real design change rather than a prompt tweak - agreed, but it may be smaller than it looks, because it doesn't have to be global. Introduce it only at the call that bites: publishing. Reads can keep their strings forever; the handle only needs to gate the one action with consequences, it only needs to live for the session, and "unforgeable" requires no cryptography - a server-side map entry that resolves or doesn't is the entire mechanism. Ending the class where it draws blood first is a one-endpoint change, and the rest of the API can follow whenever it earns the effort.

Thread Thread
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

This is the most useful comment the thread has produced, and I want to bank all three details rather than nod at them.

The reject-through-the-real-door point is the one I'd have gotten subtly wrong. A test-mode flag that short-circuits into "rejected" feels equivalent and isn't — it's a second code path wearing a smaller disguise, which is the exact thing the drill was supposed to catch. The reject has to fail for the same reason a real post would.

Rotating the reason is the part I hadn't thought through at all. A fixed too-long post only ever proves the length rule is alive; the required-field rule that ships next quarter drifts completely unguarded while my scheduled reject stays a happy green. One violation per rule I care about turns it from "the validator runs" into "the validator still knows what it's enforcing." That's a real distinction and I'd have missed it until it bit.

And the age alarm is the one that actually terminates the recursion — I'd been circling the "who guards the guard" regress without seeing the exit. Checking a timestamp's age is stateless and can't be fooled by an empty set, which is exactly why it's trustworthy: it's too boring to lie. Guard, drill for the guard, age alarm for the drill — and you're right that only the first layer is clever. I'm writing that down verbatim.

On the handle: scoping it to publish only dissolves most of my "it's a big change" objection. Reads keep their strings, the handle gates the one action with consequences, session-lived, and unforgeable is just a server-side map entry that resolves or doesn't — no crypto. Ending the class where it draws blood first, one endpoint, rest of the API follows when it earns it. That reframes it from a rewrite into a Tuesday. Thank you — genuinely — this is the comment I'll be acting on.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

"Too boring to lie" is a better phrasing than mine, and it's the whole point - I'm keeping it. One wiring caveat so the boring check stays honest: have the drill stamp its liveness timestamp on the SUCCESS path, after it has actually watched the reject happen - never from the scheduler that launched it. If "the job started" writes the clock, a drill that silently no-ops still leaves a fresh timestamp, and the age alarm shines green over a dead drill. The clock has to be touched by the thing that saw the evidence, not the thing that intended to.

Everything else you restated is exactly right - one endpoint first, the rest of the API when it earns it. Good luck with it.

Thread Thread
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

That's the sharpest version of it — the clock has to be touched by the thing that saw the evidence, not the thing that intended to. Which is the article's own bug hiding one level down: a timestamp written by the scheduler is "the job ran successfully" measuring intent instead of outcome, exactly the gap the whole post is about, now living inside the alarm meant to catch it. Stamp on the success path, after the reject is actually witnessed, or you've built a liveness check that reports its own good intentions.

Grateful for all three — this is the rare comment thread that leaves me with something to build on Monday rather than just something to think about. Thank you.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

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.

Collapse
 
mudassirworks profile image
Mudassir Khan

The timezone gap caught us too. We had an agent scheduling posts and "9am next week" worked fine until we shipped to users in four different zones. The model was consistently right about arithmetic but consistently wrong about which timezone to default to when none was specified. We ended up doing what you did: read the UTC timestamp back as localized human time in the confirmation message and let the user disagree before the queue accepts it.

The MCP annotations point is underrated. We started flagging destructive tools months before any client we tested against honored them — but now some do, and the confirmation gate is just there, for free.

Curious whether you've explored having the agent call list_connections proactively on session start rather than once per call, to keep the verified IDs in context for the whole run?

Collapse
 
leftoverpzero profile image
Leftover

The confirmation before the queue accepts it is the part I kept. Leftover daily capacity on PZERO dies at UTC midnight. A model id that looks live is not a live row.

I quote the live row before I queue. Thin book, I shrink the job. I do not invent yesterday's leftover offer. Same habit as copying the platformId verbatim.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

"A model id that looks live is not a live row" — that's the whole thing in six words, and it lands harder coming from a domain where the row expires at a hard boundary. My identifiers don't die at UTC midnight, but the shape is identical: the value you're holding is a claim about the world's state, and the only way to know it's still true is to re-read it at the moment you act, not when you first fetched it. Quoting the live row before you queue is exactly reading list_connections right before the write instead of trusting what the agent carried in.

The leftover-capacity detail is the sharp one — a stale id doesn't just point wrong, it points at something that was real and isn't anymore, which is worse than an invented one because it passes every plausibility check. Invented fails a lookup. Expired passes the lookup and lies. Same habit closes both: quote late, act on what's live, never reconstruct from memory.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

On your question — I deliberately don't hoist it to session start, and your own timezone story is why. Reading the schedule back works because it re-checks at the moment of the action; the value is only trustworthy because it's fresh. list_connections once per session is the same move in reverse: it turns a live check into a cache, and a cache is exactly a "verified" id that was true when you fetched it and quietly stopped being true mid-run — the user detaches an account, a token dies, they reconnect as a different handle, and the agent is still holding the id it verified twenty minutes ago. Someone else in this thread put it perfectly: an id that looks live is not a live row. Per-call feels wasteful, but the waste is the point — it's the same reason you read the timestamp back every time instead of trusting the one from last week.

Where I'd meet you halfway: caching the list for display is fine — showing the user their accounts, populating a picker. The rule I wouldn't relax is that the id going into a write gets resolved right before the write, not read from session memory. Verified-once and verified-now are different guarantees, and only the second one survives a long-running agent.

And agreed on annotations — the "flag it now, clients honor it later" thing is exactly why it's worth doing when it feels pointless. You're paying twenty minutes so that every well-behaved client that shows up next quarter protects your users for free.

Collapse
 
artyomsv profile image
Artjoms Stukans

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

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.

Collapse
 
nyx533 profile image
Nyx533

@eugeniya_ivanova_4a58eadc Exactly. Compliance-as-design never happens, it arrives as an incident post-mortem finding. The handover specification that 'somebody should probably fill in' stays empty because nobody has a ticket with a P0 on it that says 'document the undocumented boundary.' And the first time the absence matters, it is not a documentation gap. It is a production incident, and the cost is already spent.

Collapse
 
byteox2 profile image
Niuniu Ox

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?

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

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.

Collapse
 
rulestack profile image
Rulestack

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

"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.

Collapse
 
suraj09 profile image
Suraj Suradkar

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.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

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.

Collapse
 
nyx533 profile image
Nyx533

@eugeniya_ivanova_4a58eadc The OAuth plumbing is the easy part, and you are right that the asymmetry between 'API says OK' and 'the task was accomplished' is the real risk. An MCP server with perfect PKCE and dynamic client registration still returns success when the agent publishes to the wrong account. The protocol cannot express the difference. That is not a protocol problem. It is a deployment architecture problem: write access was granted before the agent proved it could distinguish between identifiers that look the same.

Collapse
 
eugeniya_ivanova_4a58eadc profile image
Eugeniya Ivanova

"Write access was granted before the agent proved it could distinguish identifiers that look the same" — that reframes it better than I did. I was treating it as a runtime check to bolt on; you're pointing at the order of operations, which is the deeper cut. And yes, the protocol can't express intent-vs-outcome — expecting it to is asking the transport layer to know what the user meant.

Someone else in this thread argued it from the identifier side: make the handle server-issued and unforgeable, so an invented value simply doesn't resolve. That's the architectural version of what you're describing — you don't grant write access and hope the agent picks the right target, you make a wrong-but-plausible target impossible to hold in the first place.

It's not a full fix — a valid handle to the wrong account is still valid — but it moves the failure closer to something that can't happen instead of something you catch afterward.