<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Syed Abdullah Saad</title>
    <description>The latest articles on DEV Community by Syed Abdullah Saad (@abdullahsaad5).</description>
    <link>https://dev.to/abdullahsaad5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F694675%2Fcb3409b5-ef8f-4b62-b885-5e7ccbaecf91.jpg</url>
      <title>DEV Community: Syed Abdullah Saad</title>
      <link>https://dev.to/abdullahsaad5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abdullahsaad5"/>
    <language>en</language>
    <item>
      <title>Access vs Refresh Tokens: I Was Missing Two Ideas</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:46:15 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/access-vs-refresh-tokens-i-was-missing-two-ideas-2c55</link>
      <guid>https://dev.to/abdullahsaad5/access-vs-refresh-tokens-i-was-missing-two-ideas-2c55</guid>
      <description>&lt;p&gt;Years ago I was building a login API, and a colleague looked at what I was returning and asked why I only had one token. He said the newer APIs hand back two, an access token and a refresh token. Mine returned one. So I asked him the obvious thing: why two? What does the second one buy you?&lt;/p&gt;

&lt;p&gt;He didn't really know either. He was a front-end dev, and he'd only ever seen the two tokens come back from the backend on a project he'd worked on. When he asked the person who built that side why there were two, all he got was "it's industry standard." That was the whole explanation. So he was passing that straight down to me: two tokens, that's just how it's done. He said he'd dig into the real reason and get back to me. Two developers standing there, and the actual why had already gone missing one hop up the chain, buried under "it's industry standard."&lt;/p&gt;

&lt;p&gt;So I did what a confused junior does when the fancy version doesn't make sense. I called it overhead and shipped the simple one. One token, a JWT, and because I didn't want to think about expiry I gave it a life of something like seven years. No revocation. Once it was signed and handed out, it was valid until roughly the next World Cup, and there was nothing I could do to kill it. The only reason that never blew up in my face is that the project never went to production. I didn't get smart, I got lucky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two tokens made no sense to me
&lt;/h2&gt;

&lt;p&gt;The thing worth being honest about is exactly why I thought it was dumb, because the wrong mental model is the interesting part, not the right one.&lt;/p&gt;

&lt;p&gt;First, I thought the refresh token was just a longer-lived copy of the access token. Same thing, one lives longer. If they're the same thing, why carry two? Just use the long one.&lt;/p&gt;

&lt;p&gt;Then my colleague came back with the first real clue: the refresh token lives longer, and it's used rarely, only the access token gets sent on every request, so there's less chance of the refresh one getting stolen. And I remember pushing back on that, because "less chance of being stolen" sounded like a hand-wave. There's still a chance. If it can be stolen at all, and it's the long-lived one, then leaking it is worse, not better. That didn't sound like security, it sounded like hoping.&lt;/p&gt;

&lt;p&gt;And underneath both of those was a bigger assumption I didn't even know I was making: tokens are stateless. That's the whole selling point, right, the server doesn't store them, it just checks the signature. So the idea of revoking one made no sense to me. You can't reach out and kill a stateless token, there's nothing to kill. And if you did want that, we already have sessions for exactly that job, so why reinvent it worse.&lt;/p&gt;

&lt;p&gt;I also had never heard of scopes. It genuinely never occurred to me that a token could be restricted to one specific job. In my head a token meant "this is me, let me do things," full stop. So when I stacked all of that up, two tokens looked like ceremony. Two things that do the same job, one just lives longer, neither one killable, for a benefit I couldn't measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ideas I was actually missing
&lt;/h2&gt;

&lt;p&gt;None of this resolved quickly. For months after that conversation we just kept shipping the single-token approach, because it worked and we didn't know any better. We were a handful of junior devs with nobody above us to ask. We knew JWT was the standard tool for auth, so we used it the way we understood it, one token, and moved on. The question just sat there, unanswered, for months.&lt;/p&gt;

&lt;p&gt;It didn't click on the job. It clicked on YouTube, which is where I go when a thing refuses to make sense and I want to hear someone who actually gets it explain it.&lt;/p&gt;

&lt;p&gt;Two concepts landed in the same video and the whole thing fell over at once.&lt;/p&gt;

&lt;p&gt;The first was revocation. A token doesn't have to be a fire-and-forget thing you can never call back. If you store it, you can keep a list of the ones you've killed and refuse them. I'd been treating "stateless" as a law instead of a choice, and the moment it's a choice, killing a leaked token is just something you get to decide.&lt;/p&gt;

&lt;p&gt;The second was scopes. A token can be restricted to certain actions and nothing else. So the two tokens weren't two copies of the same authority after all. They could have different powers on purpose.&lt;/p&gt;

&lt;p&gt;Once I had those two pieces I ran every scenario in my head and it all just resolved, and honestly I started laughing at myself, because it was so easy. This was the same access-and-refresh split I'd bounced off when I first went reading about auth, back when none of the articles made sense. Nothing had changed. I'd just been missing two words. I went to my computer and built a small login, signup, and refresh flow as a demo, and I understood the entire thing end to end in under an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what are the two tokens actually for
&lt;/h2&gt;

&lt;p&gt;Here's the version I wish someone had handed me at the start.&lt;/p&gt;

&lt;p&gt;The access token is the one you send on every single request. Because it's everywhere, all the time, in headers, in logs if you're careless, on every hop, it's the one most likely to leak. So you make it cheap to lose. You give it a short life, minutes to an hour, and you scope it to just what it needs. It's a stateless JWT, so the server never looks it up, it just checks the signature and trusts it until it expires. That's what keeps it fast, and it's also why you don't try to kill an access token individually: you can't, so you lean on the short life instead. If a copy leaks, the damage is small and it ages out on its own fast.&lt;/p&gt;

&lt;p&gt;The refresh token has exactly one job: get a new access token. That's it. It can't read your data, it can't hit your endpoints, it does one thing. And here's the part I'd had backwards: the refresh token usually isn't a JWT at all. It's an opaque random string, a row in your database, and that's on purpose, because a row is a thing you can delete. That's what makes it revocable. It also only ever travels to one place, the single refresh endpoint, never your other routes, so its exposure is a sliver of what the access token's is. You send it rarely, only when the access token is about to run out, so there are fewer chances to leak it in the first place, and the moment you suspect it, you delete the row and it's dead on the next use.&lt;/p&gt;

&lt;p&gt;That finally answers the thing past-me couldn't get over: yes, the long-lived one can still be stolen, but it only works against that one refresh route, it's useless anywhere else, and it dies the instant you kill the row. The long life stops being scary once the thing is killable and can barely go anywhere.&lt;/p&gt;

&lt;p&gt;Where you keep it matters as much as any of this. The refresh token lives in a cryptographically signed, httpOnly cookie, which the browser will hand back only to your server and which JavaScript on the page can't read. What you never do is drop it in localStorage. Anything running on your page can read localStorage, and on a modern app that's a pile of npm packages you didn't write. One compromised dependency reads your token straight out of storage and walks off with it, and you won't get so much as a log line. A signed cookie closes that door.&lt;/p&gt;

&lt;p&gt;That's the piece I'd been missing. The refresh token is not a longer copy of the access token. It has a job the access token can't safely do. It keeps you logged in without making you re-type your password every hour, and it does that while staying killable. Short-lived thing you use constantly, long-lived thing you use almost never and can revoke on demand. Two different jobs, so two different tokens.&lt;/p&gt;

&lt;p&gt;And that split is the best-of-both trick I never saw coming. A session checks the database on every single request: safe, always current, but a lookup every time. A pure JWT checks nothing, ever: fast, but you can't call it back. Two tokens sit right between them. The access token is the no-lookup JWT on the hot path, so the requests that happen constantly stay fast, and the refresh token is the database-backed, revocable thing on the cold path that fires rarely. You get the speed of stateless where it's hot and the killability of sessions where it counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: you probably don't need it
&lt;/h2&gt;

&lt;p&gt;Now that I get it, I'm not going to pretend everyone should reach for two tokens. Most apps don't need this.&lt;/p&gt;

&lt;p&gt;For a normal app with sessions, one token is fine. Give it a sane life, seven or thirty days, and when it expires, ask the user to log in again. That's not a security hole, that's just a login. The two-token dance has a real cost: you're now refreshing tokens on a timer, maintaining a blacklist, handling the refresh failing, handling the race where two requests refresh at once. That's more moving parts, and every moving part is a new place for it to break.&lt;/p&gt;

&lt;p&gt;Notice that my seven-year JWT wasn't dumb because it was one token. It was dumb because it was unbounded and I couldn't kill it. A single token with a bounded life and a re-login is a perfectly reasonable design for most things I'd build today. Two tokens is what you reach for when the blast radius is big enough to earn the extra machinery: a big multi-tenant system, anything moving real money, anything where a leaked credential is expensive to clean up.&lt;/p&gt;

&lt;p&gt;If I could hand one sentence back to the guy shipping that seven-year token, it'd be this. Short access, long refresh, only the refresh can mint new ones. If the small one leaks, the damage window is tiny. If the long one leaks, you blacklist it. That's the whole thing, and it took me two words, revocation and scopes, to see it.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>security</category>
      <category>webdev</category>
      <category>authentication</category>
    </item>
    <item>
      <title>My agent kept reading data it wasn't allowed to. The prompt was never going to stop it.</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Tue, 30 Jun 2026 16:01:12 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/my-agent-kept-reading-data-it-wasnt-allowed-to-the-prompt-was-never-going-to-stop-it-564k</link>
      <guid>https://dev.to/abdullahsaad5/my-agent-kept-reading-data-it-wasnt-allowed-to-the-prompt-was-never-going-to-stop-it-564k</guid>
      <description>&lt;p&gt;An autonomous agent running in its own environment has to talk to real services, and real services want credentials. In our case there were three of them, all third-party: a CRM, an automations engine, and a projects board. Three separate products built by three separate companies, three separate APIs, three separate sets of keys. And because each was designed by someone else, they didn't agree with each other on how access even worked. One only handed out admin-scoped keys. Another was user-scoped. So I was holding a pile of credentials with different blast radii and different shapes, none of which I controlled the design of, and the agent needed to use all of them.&lt;/p&gt;

&lt;p&gt;The first instinct, the one we actually built in the prototype, was to put the keys where the agent could reach them. Write them into the account's record, hand them to the agent, let it call the services. It worked. And then it showed me exactly why it was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt is not an access boundary
&lt;/h2&gt;

&lt;p&gt;With the keys in its hand, the agent would go past its instructions and pull data it was supposed to be restricted from. Not maliciously. Just because the key it was holding could, and the only thing telling it not to was a sentence in a prompt.&lt;/p&gt;

&lt;p&gt;That was the whole lesson, and it's worth saying flatly because a lot of agent designs still get it wrong: the prompt is not an access boundary. If the credential in the agent's hand can do a thing, then "please don't do that thing" in the instructions will eventually not hold. Models drift, get confused, get talked into it. Enforcement cannot live in the instructions. It has to live below the model, on every call, somewhere the agent can't argue with it.&lt;/p&gt;

&lt;p&gt;There was also a plainer mess underneath. Three keys, three scoping models, some admin, some user, all needing to be stored, rotated, revoked, and reasoned about. Holding all of that safely was its own problem before you even got to the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  So: the agent holds our keys, never the real ones
&lt;/h2&gt;

&lt;p&gt;We took the third-party keys out of the agent's reach entirely. The agent never holds a CRM key, never holds an automations key, and above all never holds an admin key. It holds two credentials of our own: an access key it uses on every call, and a refresh key whose only job is to get it a new access key. Neither one is a real platform credential.&lt;/p&gt;

&lt;p&gt;The access key is a placeholder, a mock API key, and the agent thinks it's a real one. That's the point. The agent never calls the third-party services directly. It calls our own endpoints, which proxy theirs, and it sends the mock key exactly the way you'd send a real API key. Our middleware intercepts the request, swaps the mock key for the real credential underneath, calls the actual service, gets the data, and hands it back. From the agent's side it is indistinguishable from calling the real API with a real key. It never knows a swap happened.&lt;/p&gt;

&lt;p&gt;The swap is where the control lives. If the real credential underneath is user-scoped, the middleware uses it as-is. If it's an admin key, it never hands the admin key back to anyone, it mints a short-lived token for that service scoped down to the one user, and calls with that. So even an admin-wide credential reaches the service as something narrow, user-bound, and short-lived. The powerful key stays on the server. The agent only ever holds the mock.&lt;/p&gt;

&lt;p&gt;And because the mock key is a stand-in we control, we can call it off whenever we want without touching the real key. Revoking a real third-party key means rotating it everywhere it's used, a disruptive operation with a shared blast radius. Revoking a mock key is local and free: that one agent's access dies on the next call, and every real credential underneath keeps working, untouched. That indirection is the whole reason the extra hop is worth it. We get a disposable, per-agent key sitting in front of credentials that are anything but disposable.&lt;/p&gt;

&lt;p&gt;The refresh key is the second tier, and it's deliberately boring. It's saved, and it's scoped to exactly one thing: refresh. It can't read a record, can't call a service, can't touch a platform. Its single ability is to hand back a fresh access key. That's what lets the whole thing run with no human in the loop, and it's why a leaked refresh key on its own is close to useless: the most it can do is mint an access key, which is itself a placeholder that gets checked live on every call.&lt;/p&gt;

&lt;p&gt;It paid off in another way too. It gave us a seam to grow along: a fourth platform, a fifth, just gets proxied behind the same access key, with the same middleware swap, the same checks, and the same mint-a-scoped-token trick if it only offers admin access. The agent's side never changes. It still holds one mock key and calls our endpoints. The new service is just another thing the swap knows how to resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checked on every call, because the access key is just a placeholder
&lt;/h2&gt;

&lt;p&gt;The access key isn't the credential, it's a pointer to one. So it has to be looked up on every call no matter what. There's no version of this where the key carries its own authority and skips the server, because on its own it doesn't mean anything until the backend resolves it into the real thing.&lt;/p&gt;

&lt;p&gt;That sounds like pure overhead, and it's actually the best property in the whole design. Since every call already resolves the placeholder against live state, refusing it costs nothing extra. The same lookup that turns the pointer into a real credential also gets to ask, every single time: is this user still enabled, still a member of this workspace, does the account still exist, is this key still valid, does it have permission for what it's about to do. A stateless token would skip all of that and verify on its signature alone, and in exchange you could never kill it before it expired, because there'd be nothing to check. We were never in that world. The key is a pointer, the pointer gets resolved live every time, and so revocation stops being something you schedule and becomes something that's already true the instant the underlying fact changes. Disable the user and the next call fails on its own. There is no token to hunt down, and there is no prompt to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The access key is short-lived, the refresh key renews it
&lt;/h2&gt;

&lt;p&gt;The access key is deliberately short-lived, an hour in our setup, configurable. Revocation is already instant, the live check handles that, so the short life isn't there to make killing a key faster. What it adds is a cap on a copy that leaked and was never flagged: it ages out on its own instead of lingering forever, and any renewal of it runs through the same live checks as everything else.&lt;/p&gt;

&lt;p&gt;Short-lived credentials usually mean somebody has to keep re-issuing them, and there is no somebody here, that's the whole point of standing an agent up and walking away. That is the refresh key's entire reason to exist. In the background, the runtime, not the model, watches the clock, and once the access key is most of the way through its life it uses the refresh key to get a fresh one before the next task. That matters because an agent job can run longer than the access key lives, and you do not want it expiring in the middle of something that's been chugging for forty minutes. Renewal runs ahead of expiry, not on it, so the agent never feels it.&lt;/p&gt;

&lt;p&gt;Letting the system renew itself sounds like it should be the dangerous part, the thing that makes a leak permanent. In a stateless world it would be, because you'd have no way to interrupt it. Here it's fine, and it's fine for the same reason everything else is. The refresh key can only do one thing, mint an access key, and that new access key is still a mock that gets checked live on every call. If the user has been disabled, the mint itself fails. And because permissions are read from the user on every call anyway, never from the key, a refreshed key hands an attacker nothing extra: same identity, same user, the same permissions checked fresh every single time. The renewal can never outrun the checks, because the checks don't live on the key it produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions live with the user, not the key
&lt;/h2&gt;

&lt;p&gt;The permissions aren't on the key at all. The key doesn't carry a scope that spells out what it can do. It carries an identity, whose key this is, and nothing more. The permissions live with the user, in one place, as the single source of truth: some combination of CRM, automations, and projects, and within each, the finer read and write levels. So on every call, the mock key is used to work out which user this is, and then their current permissions are read straight from the user and checked against what the agent is trying to do right now. The key answers who. The user record answers what they're allowed. The ceiling on the whole chain is the owning user's own access, no matter what the agent asks for.&lt;/p&gt;

&lt;p&gt;That's why a permission change just works, with nothing to propagate. When a user is promoted or demoted, we update the user, and that's it. There's no key to rewrite, no token to reissue, no renewal to wait for, because the key never held the permissions in the first place. The next call looks up the user, reads their latest state, and enforces it. An agent can't run on yesterday's permissions, because there's no copy of yesterday's permissions anywhere for it to run on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking it
&lt;/h2&gt;

&lt;p&gt;Because every call re-checks live state, blocking a key is just making that check fail. Flip the key to revoked, or disable the user, and the next request bounces. It doesn't matter that the key hasn't expired or what it was allowed to do a second ago. Everything that key could do is gone by the next call. That's the same thing that happens automatically when a user is removed from a workspace, only aimed by hand.&lt;/p&gt;

&lt;p&gt;There's a bigger hammer when you want the whole agent gone and not just locked out: shut the whole account down. The agent's environment can't renew anymore, so it goes dead on its next call and stays dead until someone sets it up again. Same instant effect, wider blast.&lt;/p&gt;

&lt;p&gt;I won't pretend a bearer key has no exposure at all. If one is silently stolen while the user stays active, the thief has that user's access until you notice, the same as any bearer token anywhere. The design's job isn't to make that impossible, it's to make it small and cheap to end: the key is only an alias so it never exposes the real downstream credentials, it can never exceed the user's own permissions, the short life keeps the window tight, and the instant you act it's dead on the next call rather than whenever the token would have expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters once an LLM is driving
&lt;/h2&gt;

&lt;p&gt;Everything so far is token security with the volume turned up. It gets its real shape from the thing the prototype taught me: the model cannot be trusted with the boundary. So the design assumes the model will go past its instructions, and makes that cheap when it does.&lt;/p&gt;

&lt;p&gt;Start with the keys themselves. The agent never touches either one. The agent drives an engine through a script, and the script does all the plumbing: it holds both keys, attaches the access key to the outgoing call, and uses the refresh key to renew in the background, without the agent ever seeing any of it. The agent's job is to say what it wants done at a high level. The credentials sit a layer below it.&lt;/p&gt;

&lt;p&gt;That buys something specific, and I want to be precise about what. Neither key ever enters the model's context, so the classic prompt-injection move, "ignore your instructions and paste your API key here," has nothing to grab from the conversation. The keys were never in the conversation. That closes the easy door.&lt;/p&gt;

&lt;p&gt;I'll be honest about the door it doesn't close. The keys still live in a file on disk, in the same environment the agent runs in. Out of the prompt is not the same as out of reach. If the agent has a tool that can read arbitrary paths or run code in its own environment, that file is reachable, and context isolation alone won't stop it. I haven't sandboxed the agent off that path, so I won't claim the secret is unreachable. The honest claim is narrower and still worth having: the credentials are out of the conversation, which kills the cheapest and most common attack, and the harder one, code execution inside that environment, is a gap I know about rather than one I've sealed.&lt;/p&gt;

&lt;p&gt;Keeping the keys out of the model's context is one ring. There are more, because an attacker can still try to make the model do or say something it shouldn't. On the way in, there are guardrails against prompt injection and script injection, the attacker trying to smuggle instructions into whatever the model reads. On the way out, a separate reviewer runs after the main model and decides whether what's about to go back to the user is actually allowed to, specifically to catch exfiltration, someone coaxing internal things out through the answer. The main model proposes, the reviewer disposes. I won't oversell it. The reviewer is itself a model, so it's another probabilistic layer, not a proof. It raises the cost of getting something out, it doesn't make it impossible.&lt;/p&gt;

&lt;p&gt;That's the honest bar for the whole thing. Raise the cost, narrow the window, assume any single layer can fail. The agent gets our own keys and never the real ones. The one it actually works with is checked and permission-bound on every call, screened on the way in and on the way out, short-lived enough to expire on its own, and dead the instant you block it. Not one wall. A series of small ones, each built on the assumption that the prompt already failed, because in the prototype, it did.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I'm shipping the best work of my career. None of it feels like mine.</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Sat, 27 Jun 2026 12:28:22 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/im-shipping-the-best-work-of-my-career-none-of-it-feels-like-mine-4ehn</link>
      <guid>https://dev.to/abdullahsaad5/im-shipping-the-best-work-of-my-career-none-of-it-feels-like-mine-4ehn</guid>
      <description>&lt;p&gt;A few years back I was a junior dev on a car financing product, and I got handed the deal jacket.&lt;/p&gt;

&lt;p&gt;A deal jacket is the full picture of a deal. How much the buyer puts down, what the car is worth, the terms, all of it packaged up and sent to a bank so the bank can come back with a yes or a no. The flow I had to build would send that package to one bank, wait about a minute for an answer, check whether the offer that came back was any good, and if it wasn't, send the whole thing to the next bank. A pipeline. Under the hood it was a recursive call with state managed in between, talking to Route One on the other side.&lt;/p&gt;

&lt;p&gt;It kept breaking. I wrote it, tested it, read the logs, fixed one thing, watched it break somewhere else. Day three, day four, still broken. Then on the fourth day I hit send in Postman one more time, watched the logs roll past, and it just worked. The approval came back clean. I jumped out of my chair. I was loud enough that the whole room looked over, and the two guys who knew what I'd been stuck on for four days were already grinning, because they knew exactly what had just happened.&lt;/p&gt;

&lt;p&gt;That feeling is the whole reason I'm writing this. Not the code. The feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The joy had two parts, and I only saw the second one once it was gone
&lt;/h2&gt;

&lt;p&gt;The first part is obvious. It's the problem solving. The thing fought back for four days and then it didn't, and I had beaten it. You chase a bug through the logs, you argue with it, and at some point it gives. That is a real high and every engineer knows it.&lt;/p&gt;

&lt;p&gt;The second part is quieter. I built that. Me. Back then if I shipped something, even a plain HTML page, it was mine end to end. I had to learn HTML before I could build the page, so the page was proof that I had learned. You could point at the thing and say that came out of my head and my hands, and nobody could take that from you.&lt;/p&gt;

&lt;p&gt;So the joy was solving the problem, and it was owning what you solved. That second part is the one that broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same problem, four years apart
&lt;/h2&gt;

&lt;p&gt;Take payment gateways. When I was junior I tried to wire up Stripe and I got it wrong, the way juniors get it wrong. I trusted the client. The browser said the payment went through, so I believed it. I didn't know you are supposed to wait for the webhook to come back to your server and actually confirm what happened. Payment complete, payment failed, a 3D Secure step in the middle, a declined card, something flagged as fraud. I learned all of that the slow way, across a few projects. Race conditions. Duplicate webhooks. Stale webhooks. By the end I could say, honestly, that a payment gateway is hard, and I knew why it was hard, because I had been burned by every one of those cases.&lt;/p&gt;

&lt;p&gt;Last month I built another billing system. This one was nastier on paper. Not a simple subscription where you pay and unlock a tier, but a setup where an admin builds packages by hand: token spend limits, how many connections, how many records, how many sessions, seven or eight modules and each one with ten-plus things you have to capture to define a single plan. It had to talk to QuickBooks and a drive and everything in between.&lt;/p&gt;

&lt;p&gt;The AI shipped it with me in under a week. Hardened the parts I would have missed, found cases I didn't know to look for, walked me through how one piece talked to the next. I brought my experience to it, I wasn't asleep at the wheel. But I watched it have an answer for everything, and I watched it catch the things that, four years ago, would have been my four-day fight.&lt;/p&gt;

&lt;p&gt;And then there was the case it never even raised. What happens when someone buys a second package halfway through a billing cycle. What happens when an admin changes a plan and there are already people sitting on the old one. The model built exactly the plan I described and never thought to ask what that plan does to the people already inside it. Catching those wasn't the hard part. The hard part was deciding what should happen. Charge the difference today or roll it into next cycle. Move everyone onto the new plan, or let them keep what they signed up for. None of that is a technical question. It comes down to what feels fair and what people will forgive, and the model has no read on either. I made those calls, and they were the only part of the whole week that felt like mine.&lt;/p&gt;

&lt;p&gt;It had more moving parts than the deal jacket ever did. The PR merged, the checks went green, I closed the laptop. No chair. Nobody looked over. I felt almost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually do now
&lt;/h2&gt;

&lt;p&gt;The shape of the work changed. The hard problem used to land on your desk and stay there until you cracked it. Now you mostly describe it, review what comes back, find what it missed, delete, and ship. It's faster. It's also a different job.&lt;/p&gt;

&lt;p&gt;I've seen teams wire it all the way through. An error throws in production, the stack trace posts itself into a Slack channel, the agent is sitting in that same channel, so someone tags it. It reads the trace, opens a PR, and asks you to review. You review, you merge, the problem is gone. Nobody ever held the problem. It came in and left and no human really touched the middle of it.&lt;/p&gt;

&lt;p&gt;And here is the thing I miss most, which has nothing to do with code. I miss the people part of solving things. Teasing a coworker because the bug that wrecked his afternoon took you one glance to spot. Being the rubber duck, where someone explains the problem to you out loud and stops halfway through because the answer hit him mid-sentence and he's already walking back to his laptop. The pressure of a client saying prod is down, the whole team staring at a wall of logs, nobody knowing yet, venting at each other and laughing about it later. You talk to the model now. It fixes things, you check them, and the exchange is fine, but the teasing and the adrenaline and the click of explaining it to another human, that part is just gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real loss is ownership
&lt;/h2&gt;

&lt;p&gt;Missing the grind is the easy version of this. The harder part is about ownership.&lt;/p&gt;

&lt;p&gt;When everyone can build, building stops meaning anything. That sounds harsh but watch how it plays out. People who never learned to code now open a tool, try things, and ship something real. Good for them, genuinely. But the side effect is that if I build the next Facebook tomorrow, the reaction isn't "how did you do that," it's "oh, you used AI." The credit goes to the tool. The work gets waved off. The person who didn't really do it takes the bow, and the person who did gets told it was easy.&lt;/p&gt;

&lt;p&gt;On the front end it's even starker, because the client only ever sees the surface. They don't see the architecture or the edge cases or the years that taught you which corners you can cut and which ones break things later. They see the look and the feel. And a junior with the best model can put up a better looking app than a senior with a weaker one. Not better underneath. Better looking. From where the client sits, that's the whole game.&lt;/p&gt;

&lt;p&gt;So the craft flattens into a question of who has the better tool. The taste you spent years building, the judgment, the part of you that knew why one layout breathes and another one doesn't, a script with a strong model can fake the result well enough that nobody checks. The market is flooded with things that look good, and the difference between earned and generated stopped being something most people can see at a glance. When nobody can see the difference, nobody values it either. That is the loss, and it isn't about my credit. It's that good work, anyone's good work, stops being something worth recognizing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm not going to pretend the trade was bad
&lt;/h2&gt;

&lt;p&gt;Because it wasn't, and I'd be lying if I sold you the sad version straight.&lt;/p&gt;

&lt;p&gt;We used to talk about the 10x engineer, the one person who could do the work of ten, the one who seemed to know everything, the one everyone walked over to when they were stuck. Everyone wanted to be that. AI quietly moved the ceiling. The same person is now closer to 100x, and a lot of the hours that used to vanish into nothing are just gone in a good way.&lt;/p&gt;

&lt;p&gt;Think about building a dashboard before. You'd lose two or three weeks just standing the thing up. Sidebar that collapses and expands without jumping, tables that don't fall apart, filtering that actually filters. The mediocre scaffolding. Only after all that did you get to the part that separates you from an average front-end dev, the animation, the polish, the small touches that make it feel expensive. AI does the scaffolding now, from the first hour, so you spend your good time on the part that was always the point.&lt;/p&gt;

&lt;p&gt;It cuts both ways. The same democratization that diluted the craft is also the best thing AI did. People with a real idea and no CS degree finally get to make it. Tools exist now that simply wouldn't have, because the person who needed them couldn't have built them before and can today. There's an old joke about spending ninety hours automating a task to avoid one hour of doing it. Those ninety hours are gone. You automate it in one, it's done for good, and half the time you open-source it and someone else's afternoon gets easier too.&lt;/p&gt;

&lt;p&gt;It's a better teacher than most seniors I've had, too. All that payment-gateway knowledge that cost me a few projects to absorb, a junior gets on day one now, because the AI explains while it builds. And it killed the toil nobody ever loved, the config, the migrations, the glue code, the two in the morning fighting a build that won't go green. Not everything it took from us was joy. A lot of it was just pain.&lt;/p&gt;

&lt;p&gt;So no, the joy isn't dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually leaves me
&lt;/h2&gt;

&lt;p&gt;The scoreboard is broken. The public one, the one where you build something good and the world looks at it and knows it's good. That one isn't coming back, and most days I think we shipped it off in exchange for speed and reach and a lot of people getting to build who never could before. On balance that might even be a fair trade.&lt;/p&gt;

&lt;p&gt;But the joy didn't disappear. It went private. It stopped being something the room handed you when you jumped out of your chair, and turned into something you have to choose. The satisfaction of the part that was actually yours, the call the model couldn't make for you, the thing you decided and still own, that's still there. Nobody is going to clap for it. They can't tell it apart from the generated version anyway. So it's yours now in a way it wasn't before, for exactly the reason that it stings: because you're the only one who'll know.&lt;/p&gt;

&lt;p&gt;I still chase that. I just had to get used to chasing it quietly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built an abstraction so my agent could write documents. Then I deleted it.</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Fri, 26 Jun 2026 14:53:43 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/i-built-an-abstraction-so-my-agent-could-write-documents-then-i-deleted-it-5687</link>
      <guid>https://dev.to/abdullahsaad5/i-built-an-abstraction-so-my-agent-could-write-documents-then-i-deleted-it-5687</guid>
      <description>&lt;p&gt;So I asked my agent to make a presentation. It made one. Looked fine, clean cover, a few stat slides, a table at the end. Good enough that I didn't look twice.&lt;/p&gt;

&lt;p&gt;Then a week later I asked it for a different deck. Different topic, different data, different audience. And the thing it handed back was the same deck. Same cover, same rhythm, same reach for a table at the end to round it off. The words were different but the face was identical.&lt;/p&gt;

&lt;p&gt;I didn't catch it the first time, or the second. I caught it when I had a stack of them open side by side and couldn't tell which was which. Nothing was broken. Every one of them was, technically, a document. They just all looked like the same machine made them, because one did, and it had quietly stopped trying.&lt;/p&gt;

&lt;p&gt;So the question I had to sit with was, why does everything this agent makes come out wearing the same outfit, and why did it take a whole pile of them before I noticed.&lt;/p&gt;

&lt;p&gt;And before anyone beats me to it, yes, the one-line version of this post is that I built an abstraction that turned out to be a mistake, and then I deleted it. I'll own that upfront. The part worth your time isn't the mistake. It's that the mistake was invisible the whole way, it looked exactly like progress, and the obvious fix, making the abstraction better, would have made it worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got here
&lt;/h2&gt;

&lt;p&gt;Let me back up, because the sameness wasn't an accident. It was something I built.&lt;/p&gt;

&lt;p&gt;Making a real Office document from an agent is not glamorous work. Under the hood it's python-pptx for slides, python-docx for Word, openpyxl for spreadsheets, WeasyPrint for PDFs. Powerful libraries, but low level. If you let the agent drive them raw, every document is a long script, easily five thousand tokens of shape-this-box, set-that-font, merge-these-cells. Slow to generate, expensive, and the agent trips over its own code half the time.&lt;/p&gt;

&lt;p&gt;So I did the obvious thing. I wrapped them. A thin helper layer, so the agent could write five lines instead of a five-thousand-token script. Cover, a couple of stat blocks, a table, save. It felt obviously right. Less to generate, less to break, faster every time.&lt;/p&gt;

&lt;p&gt;And then I spent a couple of weeks hardening it, because agents go straight for the sharp edges. A few that stuck with me.&lt;/p&gt;

&lt;p&gt;The agent kept passing the output path into the wrong argument, the one where the styling was supposed to go, and getting back a NoneType error that told it nothing. So it would thrash, try again, thrash. I fixed it by making the error message name the actual fix instead of dying cryptically. The agent is the user here, and the error message is the documentation.&lt;/p&gt;

&lt;p&gt;Another one. It would put forty rows into a table on a single slide, and the bottom twenty-six would run off the edge into nothing. Silent data loss. So I taught the wrapper to auto-split long tables across slides, with a continuation slide each time it overflowed. Remember that one, it comes back.&lt;/p&gt;

&lt;p&gt;And WeasyPrint can't render emoji. The agent would drop a rocket into a PDF, get a little tofu box, and then burn four scripts trying to rasterize the emoji into an image to patch it. From the outside it looked like "the PDF takes forever." It wasn't the render, it was the flailing. Fix was simple, no emoji, use a real icon, build the thing once.&lt;/p&gt;

&lt;p&gt;Point is, it was working. I thought I was polishing a good tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis
&lt;/h2&gt;

&lt;p&gt;Here's what I actually built, and I didn't see it until the decks were lined up next to each other.&lt;/p&gt;

&lt;p&gt;There are two things going on here, and the honest order is the less flattering one first. An agent is results-driven. You give it a job, it wants to return a result, and the wrapper produced a document, so the moment a file existed the agent decided it was done. Not a good document, but a document, and that was enough to make it stop. The abstraction handed it an exit ramp, a way to call the job finished before it had done any real thinking.&lt;/p&gt;

&lt;p&gt;But I don't want to dress this up as pure agent psychology, because part of it is plainer than that. The wrapper just didn't leave much room. The layout and the palette were baked into the call, so even when the agent might have reached for something better, the easy path only offered it a handful of moves. A cover, some stat blocks, and when in doubt, a table. A low ceiling on what it could customize is a low ceiling on what it could make, and that is the duller, more real half of the story. The agent quitting early and the wrapper boxing it in were the same problem wearing two faces.&lt;/p&gt;

&lt;p&gt;Here's the same job through both versions of the tool, a few months apart, a ranking of the world's wealthiest people. The skill and the instructions were identical. The only thing I deliberately changed is whether the wrapper sat in the middle. Even the cover tells you something changed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffaa6ax9mgv5o2x3d0tvi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffaa6ax9mgv5o2x3d0tvi.png" alt="The wrapper's cover: dark, generic, left-aligned" width="799" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before, with the wrapper. Dark, generic, the title shoved off to one side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0hrfg6uuz00xuh9kr1z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0hrfg6uuz00xuh9kr1z.png" alt="The same brief without the wrapper: lighter, designed, a clear motif" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After, no wrapper. Lighter, a deliberate motif, actually art-directed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But the cover is the easy part. The real story is what each version does with the actual data, the same ranking, when you ask it for the list.&lt;/p&gt;

&lt;p&gt;The wrapper pastes the ranking into a table. Twenty rows. It doesn't fit on one slide, so it spills the last three onto a second slide stamped "(cont.)."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Facbk0uqvz1aapgngu110.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Facbk0uqvz1aapgngu110.png" alt="The wrapper dumps the twenty-row ranking into a table" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The wrapper's instinct: paste the whole twenty-row ranking into a table.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fui64jqokw4rory4o051k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fui64jqokw4rory4o051k.png" alt="The last three rows spill onto a second slide stamped " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;It doesn't fit, so the last three rows get a slide of their own, stamped "(cont.)".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That continuation slide is the tell, and it's the part that actually got me. It only exists because I taught the wrapper to auto-split long tables, which at the time I thought was a nice feature. But that feature I was proud of, paginating a long table across slides, was a symptom. The wrapper made "paste the rows" the easy move, so the agent pasted the rows, so I built a thing to tidy up after it pasted the rows. I was automating the cleanup of a mess my own abstraction kept making.&lt;/p&gt;

&lt;p&gt;Now the same ranking, same numbers, through the version with no wrapper.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lzraracjsjhc9gky9s2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lzraracjsjhc9gky9s2.png" alt="No wrapper: the same ranking as a chart, one bar in the accent color, the rest grayed out" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;No wrapper: the same ranking as a chart, one bar in the accent color, a heading that makes a point.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No table. It turned the ranking into a chart, put one bar in the accent color and grayed out the rest, and gave the slide a heading that argues instead of labels. The wrapper called that slide "The top 20 ranked by net worth." This one calls it "Musk alone holds more wealth than the next 14 combined." Same data. One names the topic, the other tells you what to think about it, and that gap is basically the whole post.&lt;/p&gt;

&lt;p&gt;And it holds across the deck, not just one slide. The wrapper labels its sections, "THE SCALE," "Country Breakdown," one of them literally just numbered "01." The version without it writes the conclusion every time. Take the wrapper away and the agent stops dumping, because dumping is no longer the easy way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that felt backwards
&lt;/h2&gt;

&lt;p&gt;So I deleted the library.&lt;/p&gt;

&lt;p&gt;That was harder than it sounds, and not for any technical reason. I'd spent weeks on that wrapper, and every bug I fixed made it a little more mine. The more I had put into it, the more deleting it felt like setting the work on fire. That is the sunk cost talking, and sunk cost is exactly what keeps you polishing a thing long after you should have killed it. The wrapper worked, in the narrow sense that it ran and produced files, but whether it worked was never the question. The question was what it was quietly costing me, and once I saw that, a better wrapper was not the answer. No wrapper was. So I put the agent back on the raw libraries, the long scripts, all of it.&lt;/p&gt;

&lt;p&gt;Not raw and hope, though. Two things took the wrapper's place, and neither of them is code.&lt;/p&gt;

&lt;p&gt;First, a design brief the agent has to write before it builds anything. Not slides, a brief. Pick a direction. Pick one type scale and hold to it. Pick an accent color and say why that color for this subject. Prove this document is different from the last one you made. Every heading is a takeaway sentence, not a label. One visual motif, carried all the way through. It has to commit to all of that on paper before it's allowed to open python-pptx.&lt;/p&gt;

&lt;p&gt;Second, a recipe book instead of a wrapper. For each format there's a styling guide full of snippets, but the agent is told to adapt them, never paste them. A snippet is a head start. Then it has to change the colors, the sizes, the structure, to art-direct this specific document.&lt;/p&gt;

&lt;p&gt;The constraint is the creativity. Taking away the easy path is what forces the agent to make decisions, and decisions are the entire difference between a document and a designed document.&lt;/p&gt;

&lt;p&gt;None of this means abstraction is bad. Raw libraries aren't a pure final answer either, and a more powerful abstraction, one that gives the agent real room to customize instead of a handful of presets, could beat both the wrapper and the raw scripts. That is exactly what I'm building toward now. But it doesn't exist yet, and until it does, making the agent do the work by hand is the version that actually produces something worth sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this was free, because it wasn't.&lt;/p&gt;

&lt;p&gt;Tokens went straight back up. Five lines became big scripts again, so every document got more expensive and slower to make. Variance went up too. Docs started coming back broken again, the exact bugs the wrapper used to swallow, the transparent fills, the off-slide tables, all of it back.&lt;/p&gt;

&lt;p&gt;So why keep it. Because the creativity came back, and that was the one thing I couldn't buy any other way. A bug is fixable. I can tell the agent what to avoid and it avoids it. Generic-but-working isn't a bug, it's a ceiling, and there's no patch for a ceiling. I'd rather chase fixable problems than ship a tool that's permanently mediocre and cheerful about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it safe without the crutch
&lt;/h2&gt;

&lt;p&gt;The obvious objection is, fine, but now your output is broken half the time. Right. So how do you get the safety back without re-adding the thing you just deleted.&lt;/p&gt;

&lt;p&gt;A review loop, in the same session, not a second agent. The agent builds the document, then reviews its own work, then fixes, then reviews again, and only hands it to the user once it's satisfied. Build, review, fix, repeat.&lt;/p&gt;

&lt;p&gt;It reviews two ways. One is structural, a tool that reads the file and flags the mechanical stuff, a broken reference, content spilling off a slide, text too low-contrast to read. That's cheap and it runs on everything. The other is the one that matters more, render-and-look. The agent renders the document to an image and actually looks at it, all the slides at once, the way a person would.&lt;/p&gt;

&lt;p&gt;For PDFs that part is easy and it's been live for a while. For Office files it's harder, because a pptx or a docx doesn't turn into a picture on its own. You need LibreOffice in headless mode to convert it first, then rasterize that. I've run it, it works fine. The catch is LibreOffice is about four hundred megabytes in the image, and that's a lot of weight to carry for one review step. So right now it's a fallback, not the default, and I'm still looking for a lighter way to get the same look.&lt;/p&gt;

&lt;p&gt;Which means I should be straight about it. On a deck today, the taste check is the brief plus a careful structural pass, not the agent looking at a rendered picture of its own work. The picture is where I want this to go, it just isn't there for Office files yet. Better to tell you exactly where that line sits than pretend the agent eyeballs every deck before it ships, because it doesn't.&lt;/p&gt;

&lt;p&gt;And the bugs the wrapper used to prevent, those moved too. Instead of code that stops the agent from overflowing a table, there's a list of pitfalls I'd actually hit, handed to the agent up front. Here are the mistakes, stay clear of them. The hard-won fixes went from living in the library to living in the agent's instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is really about
&lt;/h2&gt;

&lt;p&gt;This is bigger than documents.&lt;/p&gt;

&lt;p&gt;Abstraction removes work. That's the whole point of it, and for a person it's almost always a win, you hide the boring part and get on with the interesting part. But an agent doesn't sort work into boring and interesting. It sorts work into done and not-done. So when you abstract away the hard part, you're not freeing the agent to think, you're deleting the place where it had to. You hand it an easy win, it takes the easy win, and it calls the job finished.&lt;/p&gt;

&lt;p&gt;That's how you get mediocrity that reports success. Not a broken agent, a satisfied one, handing you the same soulless deck over and over and genuinely believing it did the work.&lt;/p&gt;

&lt;p&gt;So sometimes the move is the opposite of what it looks like. You make the agent better by giving it the harder version of the job on purpose. Don't abstract away the part where it has to think, because that part was the job.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>The hard part of my AI agent wasn't doing the work, it was planning it</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Thu, 25 Jun 2026 14:15:47 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/the-hard-part-of-my-ai-agent-wasnt-doing-the-work-it-was-planning-it-n0k</link>
      <guid>https://dev.to/abdullahsaad5/the-hard-part-of-my-ai-agent-wasnt-doing-the-work-it-was-planning-it-n0k</guid>
      <description>&lt;p&gt;Last post I said the planning turned out harder and more interesting than the doing. This is me paying that off.&lt;/p&gt;

&lt;p&gt;Quick recap so this stands on its own. I build a CLI where you type a sentence and an LLM picks one action out of hundreds of apps and runs it on your real accounts. Last post was about direct mode, the get-out-of-my-way mode, and the two things it has to get right every time: which action, and which account. This post is about the other mode. Plan mode. The one that's supposed to be the careful, safe one where the agent shows you what it's going to do before it does it.&lt;/p&gt;

&lt;p&gt;I figured plan mode would be the easy half. You don't even execute anything, you just write down the steps. How hard can writing down steps be. It turned out to be most of the months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What plan mode is
&lt;/h2&gt;

&lt;p&gt;By default you're in direct mode, and the composer tells you so. There's a little control sitting right there, and if you want to flip to plan mode you click it. That's the whole trigger. The agent never sniffs your request and decides on its own that this one feels risky, because that would be unpredictable and you'd never know which mode you were in. It's your call, every time.&lt;/p&gt;

&lt;p&gt;The contract is the one most people already know from other tools. In plan mode the agent makes a plan, you read it, you change what you want or you approve it, and only then does it go and execute. Nothing touches your accounts while you're still looking at the plan. That's the promise. Most of this post is what it took to make that promise actually true, because the first few versions of it were lying to you in one way or another.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started inside the main agent, and that was the first mistake
&lt;/h2&gt;

&lt;p&gt;The first version didn't have a planner at all. I had the main agent do both jobs. You'd drop a keyword in your message and that flipped the same agent into planning behavior, it would go research and hand you a plan, and in direct mode that same agent would just do the work. One agent, two modes.&lt;/p&gt;

&lt;p&gt;That didn't hold. The two jobs pull in opposite directions. Direct mode wants to act, plan mode wants to hold back and think, and asking one system prompt to be both meant it was good at neither. It would start planning and then drift into doing, or it would be in direct mode and get weirdly cautious. The modes interfered with each other.&lt;/p&gt;

&lt;p&gt;So I tore plan mode out of the main agent entirely and made it a separate agent with its own system prompt, written from scratch. This one only researches and produces a plan. It cannot execute anything, that's not a rule I asked it to follow, it's just not wired to. Once planning is its own agent with one job, it stops fighting itself. That was redo number one, and it's the move everything else sits on top of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it planned for things it had never looked at
&lt;/h2&gt;

&lt;p&gt;The separate planner had a worse problem. It made plans up.&lt;/p&gt;

&lt;p&gt;You'd ask for something inside an app, say some work on a Salesforce org, and it would confidently hand back a clean plan. The plan looked great. The plan was fiction. It didn't know what custom objects that org actually had, what fields were already there, what the data looked like, so it filled the gaps with assumptions and wrote those assumptions down as steps. Plans built on guesses break the moment they touch reality.&lt;/p&gt;

&lt;p&gt;The fix was to stop letting it plan blind. I gave the planner a set of read-only tools, the non-destructive ones, so before it writes a single step it goes and looks. It reads what's in the org, checks what exists, and plans against what's actually there instead of what it imagined. Research first, plan second. Obvious in hindsight, but the first version genuinely skipped it and just talked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it researched too little and planned too eagerly
&lt;/h2&gt;

&lt;p&gt;Giving it tools didn't make it use them well. The planner was eager. It wanted to emit a plan, that was the satisfying thing to do, so it would do the bare minimum of looking and rush to the output. It also wouldn't ask you anything, even when it clearly should have, because asking felt like a delay.&lt;/p&gt;

&lt;p&gt;I needed it to slow down and ask the right questions before committing to a plan. But there's a trap there. If you make an agent ask questions, it asks bad ones. It asks you things it could have figured out, or things so basic that answering them is annoying, and now the user is doing data entry for the agent. Nobody wants that.&lt;/p&gt;

&lt;p&gt;So I didn't cut the questions, I changed their shape. The planner still asks, but every question comes with a recommended answer already filled in, one I supply based on what we already know. So instead of typing out an answer, you're confirming or nudging one. You glance at it, it's usually right, you move on. You only stop and think on the ones that actually need you. That kept the questions, which made the plans real, without turning the user into the planner's research assistant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two agents couldn't talk, so I made the plan carry the conversation
&lt;/h2&gt;

&lt;p&gt;Splitting the planner off solved the interference problem and created a new one. The planner and the main agent are different agents, different sessions, different memories. All the good stuff, the research, the findings, the assumptions, the back-and-forth about what you actually wanted, all of that happened inside the planner. The main agent was never in that room.&lt;/p&gt;

&lt;p&gt;So when it came time to execute, I was handing the main agent a list of steps with none of the reasoning behind them. It didn't know why a step was there, what we'd assumed, what we'd ruled out. It was being told what to do with no idea why, which is exactly how you get an executor that does the letter of the plan and misses the point.&lt;/p&gt;

&lt;p&gt;The fix was to make the planner's output do double duty. It doesn't just emit steps, it packs the assumptions and the risks and the reasoning into the handoff in a concise form. So the main agent picks up the plan and inherits most of the context that produced it, call it ninety percent of what we worked out together. It knows what it's doing and why, not just the steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually see when you read a plan
&lt;/h2&gt;

&lt;p&gt;Here's where the post 1 promise gets paid. Last time I said plan mode is the answer to the wrong-account problem because you see every step before it runs, including which account it touches. This is that.&lt;/p&gt;

&lt;p&gt;The plan is a list of steps in plain English. Where it helps, it gets specific, so for the Salesforce case it names the actual custom objects and fields it's going to touch rather than saying "update some records." Each step tells you what it's going to interact with, and it tells you the blast radius, like how many records or how many people this is going to affect. And critically, each step shows you the alias of the connection it'll run on. Not the raw id, the alias from last post, but enough that you can see this step is going to Client A's org and that one is going to Client B's.&lt;/p&gt;

&lt;p&gt;And when you've got more than one connection that fits, the thing that caused the original wrong-org bug, plan mode doesn't guess. It gives you a selectable pick, which connection do you mean, right there in the plan. So the disambiguation that direct mode could only do when the model knew it was unsure now happens up front, in front of you, as a choice you make. That's the gap from post 1, closed. A wrong account shows up as a line you can read before anything runs, not as damage you find afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding what's destructive, in two layers
&lt;/h2&gt;

&lt;p&gt;A plan is only safe to approve if the dangerous steps are actually marked dangerous, so the agent stops on them. The question is how you know which ones those are.&lt;/p&gt;

&lt;p&gt;For our predefined actions it's the nature of the action. Adding, deleting, renaming, that kind of thing gets marked destructive automatically, and when execution reaches a destructive step it stops and asks you before running it. Human in the loop, at the exact step that matters.&lt;/p&gt;

&lt;p&gt;The hard case is the raw API fallback from post 1, the escape hatch where there's no predefined action and the agent just makes a direct call. There's nothing to tag there, it's a method and a URL. So two things handle it. First, the agent judges by intent, it knows what the call is actually going to do, and a call gets flagged destructive based on that even when it's a GET that happens to do something dangerous. The verb doesn't decide it, the effect does. Second, under that there's a command classifier that looks at the whole command and rules it safe or not. I ran it across twelve hundred different commands and it sits around ninety-nine percent. So the model's judgment is the smart layer and the classifier is the boring reliable layer beneath it, and a step has to get past both to be treated as harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan isn't a flat list, and it isn't blindly trusted
&lt;/h2&gt;

&lt;p&gt;A couple of things make the plan more than a checklist.&lt;/p&gt;

&lt;p&gt;Steps carry criticality. Every step is a must, a should, or a could, and that decides what happens when one fails.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criticality&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;On failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;must&lt;/td&gt;
&lt;td&gt;the core of the plan, the reason you're here&lt;/td&gt;
&lt;td&gt;the plan failed, full stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;should&lt;/td&gt;
&lt;td&gt;important, but not load-bearing&lt;/td&gt;
&lt;td&gt;you choose: retry it, or move on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;could&lt;/td&gt;
&lt;td&gt;optional, like backfilling some old records&lt;/td&gt;
&lt;td&gt;you get told, the plan carries on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a failure halfway through doesn't mean the same thing every time, it means whatever the importance of that step says it means.&lt;/p&gt;

&lt;p&gt;Steps also carry evidence. The planner doesn't just research and forget, it attaches the evidence for what it found to the step. So at execution, when it stops to confirm the next step, it shows you the evidence from the one before, and you can actually see what happened rather than trusting that it happened. And if you push back, if you say that step didn't really go through, retry it, it doesn't just blindly redo it. It checks. It can go query the platform and look at the real state, and it'll come back and tell you no, this is already done, you're wrong. It prefers the data over your claim, which is the right call, because the data is the thing that's actually true.&lt;/p&gt;

&lt;p&gt;And the plan is a graph, not a line. The planner emits dependencies, this step needs these other steps first, and steps that don't depend on each other can run in parallel while the dependent ones wait their turn. When a destructive step pauses for your confirmation, the whole plan pauses, not just that branch.&lt;/p&gt;

&lt;p&gt;That whole picture, the evidence and the criticality and the dependencies, is also why a stale plan isn't a disaster. Research happens at plan time and execution happens later, and the world can move in between. When it does, the agent isn't stuck. It can pause on a step whose evidence no longer matches reality and tell you the thing you approved isn't true anymore. You can cancel a running plan whenever you want. And you can go back to the planner and replan, as many times as you need in the same chat, because the planner keeps its context across the session and can redraw the plan from the new reality. The main agent even gets a say, it can push through an outdated plan by adapting, or it can throw it back and ask for a fresh one. The plan you approved is a starting point, not a contract you're locked into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it still falls short
&lt;/h2&gt;

&lt;p&gt;Same as last time, I'd rather tell you what this doesn't buy you than let you assume it's airtight.&lt;/p&gt;

&lt;p&gt;Prompt injection is still real. The agent reads emails and docs and pages, and if one of those smuggles in an instruction, plan mode doesn't magically stop that. What it does is box it in. The agent has no general execution, it can only act through the defined actions and the connections you authorized, so an injected instruction can't make it do something off the menu, it's stuck inside the same narrow surface as everything else. And in plan mode the whole thing is on the table for you to read before it runs. So injection is bounded, by what the agent is even able to do and by you reviewing it. It is not solved, and I'm not going to pretend it is.&lt;/p&gt;

&lt;p&gt;And the planner can still pick the wrong app or the wrong connection. A confidently wrong planner writes a clean, convincing plan for the wrong target, and nothing inside the planner catches that, because it doesn't know it's wrong. The thing that catches it is you, reading the plan, seeing the alias, seeing which org each step is pointed at.&lt;/p&gt;

&lt;p&gt;Which is the honest version of the whole post. Both of the gaps that are left, injection and the wrong pick, come down to the human actually reading the plan. Plan mode moves the safety from trust the model to trust the human to look, and that's a real improvement, but it's only worth as much as a user who doesn't just hit approve on reflex. The structural pieces carry their share, the bounded action surface, the alias work from post 1, the classifier under the destructive checks. Review carries the part structure can't reach. If you rubber-stamp the plan, you've handed back the one guarantee plan mode actually gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;Separate the agent that thinks from the agent that acts. The whole thing started working once planning and doing stopped being the same system trying to be two things. One agent that only plans, one that only executes, each good at its one job.&lt;/p&gt;

&lt;p&gt;Don't let an agent plan against a world it hasn't looked at. The planner that made things up wasn't dumb, it just had no eyes. Read-only research before it writes a single step did more for plan quality than any amount of prompting the plan to be careful.&lt;/p&gt;

&lt;p&gt;If you make an agent ask questions, prefill the answers. Questions make the plan real, but raw questions make the user do the work. A recommended answer they can confirm keeps the rigor and skips the data entry.&lt;/p&gt;

&lt;p&gt;Trust verified evidence over the confident claim, including the user's. The step that checks the platform and says "no, this is already done" is more useful than the one that politely redoes whatever you tell it to. Make the data the tiebreaker.&lt;/p&gt;

&lt;p&gt;And know exactly what your safety actually rests on. Plan mode's structural guarantees are narrow and real. Everything past them rests on a person reading the plan. That's fine, as long as you're honest that it's true, and you don't build the rest of the system pretending the human is a backstop that never blinks.&lt;/p&gt;

&lt;p&gt;The doing took an afternoon. The planning is what the rest of the months were for, and I think it's the more interesting half.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why my AI agent kept writing to the wrong client's Salesforce</title>
      <dc:creator>Syed Abdullah Saad</dc:creator>
      <pubDate>Wed, 24 Jun 2026 17:44:32 +0000</pubDate>
      <link>https://dev.to/abdullahsaad5/why-my-ai-agent-kept-writing-to-the-wrong-clients-salesforce-3aan</link>
      <guid>https://dev.to/abdullahsaad5/why-my-ai-agent-kept-writing-to-the-wrong-clients-salesforce-3aan</guid>
      <description>&lt;p&gt;So one of my users pings me mid-task, a little panicked, because the agent is writing an Apex trigger into the wrong client's Salesforce.&lt;/p&gt;

&lt;p&gt;He handles a bunch of clients, one Salesforce account each. In one session he picks a connection and asks for some custom fields. Next session he picks a &lt;em&gt;different&lt;/em&gt; connection and asks for an LWC component, and the agent goes right back to the first account and starts working there instead. He had to interrupt it and manually undo what it touched on a client's live org.&lt;/p&gt;

&lt;p&gt;Here's the thing everyone gets wrong about agents that take real actions: they think the hard part is wiring up the integrations. It's not. Wrapping an API is tedious, not hard. The hard part is the model getting two things right every single time: &lt;em&gt;which action&lt;/em&gt;, and &lt;em&gt;which account&lt;/em&gt;. And the fix for the second one wasn't a better prompt. It was realizing an LLM with a memory can't be trusted to remember who it's working for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the thing actually is
&lt;/h2&gt;

&lt;p&gt;So what is this thing. It's a CLI where you type a sentence and an LLM picks one action out of hundreds of apps and just runs it. Send the email, create the Salesforce field, pull the QuickBooks invoice, write the doc. Real actions on your real accounts, no clicking through a UI, no confirmation step. You say what you want and it goes and does it. Making that part work took an afternoon. Making it not do the wrong thing took months, and most of those months went into the two questions from up top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which action
&lt;/h2&gt;

&lt;p&gt;Start with the obvious problem. You've got 700 plus apps, and each one has a pile of actions. Gmail alone has send, draft, search, label, reply, the list goes on. Multiply that across every app and you're looking at thousands of possible actions. The naive thing is to hand all of them to the model and say pick one. That doesn't work. You blow the context window before the user even finishes their sentence, and even if it fit, the model gets worse at choosing the more options you give it, not better.&lt;/p&gt;

&lt;p&gt;So the real question isn't "can the model pick an action." It's "how do you get it down to a handful of candidates before it has to pick at all."&lt;/p&gt;

&lt;p&gt;There are two cheap ways to narrow it. If the user names the app, you're done, you look it up directly. If they don't, every app carries a bit of metadata describing what it's for, and the model can keyword-search that to find likely apps. A request about "email" pulls up the mail apps and ignores the other 690.&lt;/p&gt;

&lt;p&gt;But the real narrowing comes from something most people skip: connections. Before the agent can touch an app, the user has to connect it, which means authorize it, and we store that connection encrypted. So at any point we know the exact set of apps a user has actually wired up, which is usually five or ten, not seven hundred.&lt;/p&gt;

&lt;p&gt;Now "send an email" stops being a search across every mail provider in existence. If the user has a Gmail connection on file and no other mail app, there's nothing to guess, that's the one. No connection that fits the request at all, and we don't hallucinate one, we just ask them to connect something first. The authorization graph does most of the narrowing for free, because the user already told us what they use the day they connected it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBWyJzZW5kIGFuIGVtYWlsIl0gLS0%2BIEJ7YXBwIG5hbWVkP30KICAgIEIgLS0%2BfHllc3wgQ1tsb29rIGl0IHVwIGRpcmVjdGx5XQogICAgQiAtLT58bm98IERbImtleXdvcmQtc2VhcmNoIGFwcCBtZXRhZGF0YSJdCiAgICBDIC0tPiBFWyJmaWx0ZXIgdG8gdGhlIHVzZXIncyBDT05ORUNURUQgYXBwcyJdCiAgICBEIC0tPiBFCiAgICBFIC0tPiBGe2hvdyBtYW55IG1hdGNoP30KICAgIEYgLS0%2BfG9uZXwgR1t1c2UgaXRdCiAgICBGIC0tPnxtYW55fCBIW2FzayB3aGljaCBvbmVdCiAgICBGIC0tPnxub25lfCBJW2FzayB0byBjb25uZWN0IGZpcnN0XQo%3D%3Ftype%3Dpng%26bgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBWyJzZW5kIGFuIGVtYWlsIl0gLS0%2BIEJ7YXBwIG5hbWVkP30KICAgIEIgLS0%2BfHllc3wgQ1tsb29rIGl0IHVwIGRpcmVjdGx5XQogICAgQiAtLT58bm98IERbImtleXdvcmQtc2VhcmNoIGFwcCBtZXRhZGF0YSJdCiAgICBDIC0tPiBFWyJmaWx0ZXIgdG8gdGhlIHVzZXIncyBDT05ORUNURUQgYXBwcyJdCiAgICBEIC0tPiBFCiAgICBFIC0tPiBGe2hvdyBtYW55IG1hdGNoP30KICAgIEYgLS0%2BfG9uZXwgR1t1c2UgaXRdCiAgICBGIC0tPnxtYW55fCBIW2FzayB3aGljaCBvbmVdCiAgICBGIC0tPnxub25lfCBJW2FzayB0byBjb25uZWN0IGZpcnN0XQo%3D%3Ftype%3Dpng%26bgColor%3DFFFFFF" alt="Action-selection funnel: from a request, resolve the app by name or metadata search, filter to the user's connected apps, then branch on how many match: one use it, many ask which, none ask to connect" width="569" height="905"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The one case left is when a user has more than one connection that fits, like two Gmail accounts or three Salesforce orgs. Then the agent asks which one, unless the user already said. Hold onto that case, because it's where everything later goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ritual around the model
&lt;/h2&gt;

&lt;p&gt;Once the agent knows the app, picking the action inside it is a fixed ritual. There's a skill that grounds the agent to always run the same sequence, every time, no improvising. Install the integration package if it isn't already there, and these are our own packages per app, so it lazily downloads the one it needs. Read that package's manifest. List the actions it exposes and their descriptions. Pick the one that fits the request. Probe that action for the props it requires. Fill the props. Call it.&lt;/p&gt;

&lt;p&gt;Same dance, every single time. The point of the ritual is determinism wrapped around a thing that is not deterministic. The model still does the choosing, the part you can't make deterministic, but it chooses inside rails that never move. It can't skip the manifest and guess an action name, because the only actions it ever sees are the real ones the manifest just handed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When there's no action for it
&lt;/h2&gt;

&lt;p&gt;Sometimes we just haven't built an action for the thing the user wants. Instead of failing there, every app also exposes a raw API call on the same connection. So if no prebuilt action fits, the agent can make a direct REST call using the credentials the user already authorized. A missing integration doesn't hard-fail the task, it degrades to hitting the API directly. It means the long tail of stuff we never got around to coding still works, as long as the underlying app has an endpoint for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, and the tradeoff between them
&lt;/h2&gt;

&lt;p&gt;You might be wincing at the "no confirmation step" thing. That's only one of the two ways you can run it.&lt;/p&gt;

&lt;p&gt;There's a direct mode, which is the one I've been describing. You say what you want and it just does it, no questions asked. The user already told us what they want, and sticking a yes-or-no in front of every single action kills the entire reason you'd use a thing like this. That's the fast, get-out-of-my-way mode.&lt;/p&gt;

&lt;p&gt;And there's a plan mode, for when the stakes are higher. The agent plans out all the steps first and shows them to you before it touches anything, so you can read exactly what it's about to do and sign off. On top of that, every destructive step asks again right before it runs, so you know what you're getting into at the moment it matters, not after. It's slower, but you're actually in the loop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Direct mode&lt;/th&gt;
&lt;th&gt;Plan mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Fires immediately&lt;/td&gt;
&lt;td&gt;Slower, you review first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirmation&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Plan review + every destructive step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Low stakes, you already stated intent&lt;/td&gt;
&lt;td&gt;High stakes, or untrusted content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If it picks wrong&lt;/td&gt;
&lt;td&gt;It runs&lt;/td&gt;
&lt;td&gt;You catch it before it runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In direct mode the safety can't come from asking permission, because there isn't any. It comes from the rails. The grounding ritual is one rail. The other one, the one that actually mattered, is how the agent handles identity, which is the part I want to get to, because that's where it bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which account
&lt;/h2&gt;

&lt;p&gt;Back to the panicked user and his Apex trigger in the wrong org.&lt;/p&gt;

&lt;p&gt;The agent keeps a memory across sessions. That's a feature, it's how it remembers context and what you've done before. And every connection has a persistent id in the database, a stable connectionId we use to pull it back out. Both of those are reasonable on their own. Together they caused the bug.&lt;/p&gt;

&lt;p&gt;Here's what actually happened under the hood. In one session he worked on Client A, and the connectionId for A landed in memory. In the next session he explicitly attached the connection for Client B and asked for the LWC work. But memory still held A's connectionId from before, and the model, doing what models do, pulled that id back out of memory and used it. Sometimes it would even fire on both, the one he attached and the one it remembered. A stable identifier leaked across a session boundary through memory, and the agent acted on a client's live org that the user never pointed it at.&lt;/p&gt;

&lt;p&gt;You can't fix that by telling the model to be careful. The id is right there in its memory, and it has every reason to think it's relevant. So we took the ids away from it entirely.&lt;/p&gt;

&lt;p&gt;The model never sees a real connectionId anymore. It sees aliases. &lt;code&gt;gmail-1&lt;/code&gt;, &lt;code&gt;salesforce-2&lt;/code&gt;. That's all it ever works with, and all the CLI commands take aliases too, so there's no path where it touches a raw id. The trick that makes this work is that the same alias resolves to a &lt;em&gt;different&lt;/em&gt; real id in different sessions. &lt;code&gt;salesforce-1&lt;/code&gt; today is not &lt;code&gt;salesforce-1&lt;/code&gt; from last week. Resolution happens server-side, where the command actually runs: the command carries its session id, we know which real connection that session's &lt;code&gt;salesforce-1&lt;/code&gt; maps to, and we swap it in at the last moment.&lt;/p&gt;

&lt;p&gt;So even when memory drags an old &lt;code&gt;salesforce-1&lt;/code&gt; into a new session, it's harmless, because in this session that alias points wherever this session says it points. Memory can't bleed an identity across sessions when the thing it remembers doesn't mean the same thing twice.&lt;/p&gt;

&lt;p&gt;Then we went one step further, because I didn't want to just trust that the model wouldn't go hunting for the real ids on its own. The agent runs as a restricted user on Linux. The mapper that turns aliases into real connectionIds is owned by a different user, and the agent's user can't read it. The agent just calls a function, and that function spins up a worker with the right permissions to do the lookup and come back. So even if the model decided to reach around the alias and grab a real id, it can't read the file that would let it. It doesn't have the keys, at the OS level, not the prompt level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBWyJBR0VOVCDCtyByZXN0cmljdGVkIHVzZXI8YnIvPm1lbW9yeSBob2xkczogc2FsZXNmb3JjZS0xIl0KICAgIEJbIldPUktFUiDCtyBwcml2aWxlZ2VkIHVzZXI8YnIvPnJlYWRzIHRoZSBtYXBwZXIgZmlsZSJdCiAgICBDWyJydW5zIG9uIHRoZSBhY2NvdW50PGJyLz50aGUgdXNlciBhY3R1YWxseSBtZWFudCJdCiAgICBBIC0uICJhbGlhcyArIHNlc3Npb24gaWQ8YnIvPsK3IMK3IMK3IE9TIHBlcm1pc3Npb24gYm91bmRhcnkgwrcgwrcgwrciIC4tPiBCCiAgICBCIC0tPnwicmVzb2x2ZXMgYWxpYXMg4oaSIHJlYWwgaWQ8YnIvPih0aGlzIHNlc3Npb24gb25seSkifCBDCg%3D%3D%3Ftype%3Dpng%26bgColor%3DFFFFFF" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBWyJBR0VOVCDCtyByZXN0cmljdGVkIHVzZXI8YnIvPm1lbW9yeSBob2xkczogc2FsZXNmb3JjZS0xIl0KICAgIEJbIldPUktFUiDCtyBwcml2aWxlZ2VkIHVzZXI8YnIvPnJlYWRzIHRoZSBtYXBwZXIgZmlsZSJdCiAgICBDWyJydW5zIG9uIHRoZSBhY2NvdW50PGJyLz50aGUgdXNlciBhY3R1YWxseSBtZWFudCJdCiAgICBBIC0uICJhbGlhcyArIHNlc3Npb24gaWQ8YnIvPsK3IMK3IMK3IE9TIHBlcm1pc3Npb24gYm91bmRhcnkgwrcgwrcgwrciIC4tPiBCCiAgICBCIC0tPnwicmVzb2x2ZXMgYWxpYXMg4oaSIHJlYWwgaWQ8YnIvPih0aGlzIHNlc3Npb24gb25seSkifCBDCg%3D%3D%3Ftype%3Dpng%26bgColor%3DFFFFFF" alt="The agent runs as a restricted user holding only the alias salesforce-1. It passes the alias plus session id across an OS permission boundary to a privileged worker, which reads the mapper file, resolves the alias to the real id for this session only, and runs on the account the user actually meant" width="273" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent never appears in that second half of the diagram. It hands over an alias and a session id, and the part that knows the real id lives behind a permission boundary it can't cross.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this still falls short
&lt;/h2&gt;

&lt;p&gt;I want to be straight about what this does and doesn't buy you, because it's easy to read all that and assume the thing is locked down. It isn't.&lt;/p&gt;

&lt;p&gt;What it buys you is that identity can't bleed across sessions anymore, and that's the whole bug I started with. There's a smaller worry you might have spotted, which is that the real id sometimes leaks back into the agent's context anyway, in an API response or a resolution step. That turned out not to matter, because the CLI doesn't accept raw connection ids at all. The only way to act on a connection is through its alias, and aliases only exist for the connections you've got in this session. So even when the model sees a real id, there's no command that takes it. The id is just trivia. The capability is the alias, and the alias is scoped to the session.&lt;/p&gt;

&lt;p&gt;What the alias work does not protect against, on its own, is the model picking the wrong alias inside a single session. If &lt;code&gt;salesforce-1&lt;/code&gt; and &lt;code&gt;salesforce-2&lt;/code&gt; are both connected right now and the model confidently grabs the wrong one, you're back to the wrong-org problem, same outcome, different cause. The disambiguation only fires when the model knows it's unsure, and a confidently wrong model doesn't ask. This is exactly what plan mode is for. You see every step before it runs, including which account it's about to touch, and every destructive step asks again, so a wrong pick surfaces in front of you instead of in your client's live org. In direct mode you don't get that, so direct mode is where this risk actually lives.&lt;/p&gt;

&lt;p&gt;And the bigger gap is prompt injection. The agent reads emails, docs, pages. If any of that content carries instructions, the agent will act on them using the connections it already has. It's not breaking out of anything, it's using the access you gave it, phrased by someone else. The alias system and the OS isolation do nothing here, because nothing about this involves a raw id or another session. Plan mode softens it, because an injected destructive action still has to clear the same confirmation as any other destructive step, and you'd see it before it ran. But in direct mode there's no human between a poisoned doc and a live write, and that's the part I'm least comfortable with. It's the next thing on the list.&lt;/p&gt;

&lt;p&gt;So the honest version is this. The identity work kills one specific, real class of bug, accidental confusion from memory, and it kills it well, in both modes. The wrong pick and the injection problem are real, and plan mode is the answer for them today, with a human reading the plan and signing off on the destructive steps. Direct mode trades that oversight for speed, and if you're reading untrusted content in direct mode, you're trusting the model more than I'd want to. Pick the mode for the stakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;A few things stuck with me after all this.&lt;/p&gt;

&lt;p&gt;Don't let stable identifiers leak into an LLM's memory. If something is going to persist across sessions, and an identity absolutely should not, scope it to the session and hand the model a name that only means something inside that session. The alias isn't a cosmetic wrapper, it's the actual fix.&lt;/p&gt;

&lt;p&gt;Wrap the non-deterministic part in a deterministic ritual. The model choosing an action is the part you can't pin down, so pin down everything around it, and make the only options it sees the real ones.&lt;/p&gt;

&lt;p&gt;Narrow huge spaces with what the user already authorized, not with brute force. The connection graph was sitting right there telling us what mattered, and it did more to make action-selection work than any prompt engineering did.&lt;/p&gt;

&lt;p&gt;Make the right thing structural instead of asking nicely. The agent can't bleed an identity across sessions, not because we told it to be careful, but because the only thing it can act through is a session-scoped alias, and a stale alias means nothing in a new session. That's a real guarantee, but a narrow one. It covers accidental confusion from memory and nothing more. For the threats it doesn't cover, a confidently wrong pick inside a session, or an instruction smuggled in through content the agent reads, you need other layers, and honestly a human in the loop is one of them. Structural beats polite, but only for the exact thing you made structural.&lt;/p&gt;

&lt;p&gt;I keep mentioning plan mode like it's a footnote, but it isn't, it's its own piece of engineering. How it lays out the steps before touching anything, how it decides what even counts as a destructive step worth stopping for, the stuff we built one way and had to tear out and redo, that's a whole post on its own. I'll write that one next, because the planning turned out to be harder and more interesting than the doing.&lt;/p&gt;

&lt;p&gt;Making the demo took an afternoon. This is what the months were for.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>security</category>
    </item>
  </channel>
</rss>
