[{"data":1,"prerenderedAt":4667},["ShallowReactive",2],{"header-blog-translations-/en/blog":3,"blog-list-en":4},null,[5,634,1480,2390,2854,4347],{"id":6,"title":7,"author":8,"body":9,"date":618,"description":619,"draft":620,"extension":621,"image":3,"meta":622,"navigation":623,"path":624,"seo":625,"stem":626,"tags":627,"translationKey":632,"__hash__":633},"blog_en/blog/en/from-prompts-to-patterns.md","The Agent Knows What It Knows","Patrick Hofmann",{"type":10,"value":11,"toc":605},"minimark",[12,16,37,40,45,67,92,95,99,102,129,132,156,159,163,166,177,185,200,203,207,225,232,269,272,283,286,290,293,300,303,313,343,350,360,364,393,400,429,447,450,454,457,467,497,515,525,529,532,542,545,549,560,564,567,570,583,586],[13,14,15],"p",{},"Widening was always clear to me.",[13,17,18,19,23,24,27,28,32,33,36],{},"It's an obvious property of a system where every command is approved individually: at some point there has to be a way to approve a ",[20,21,22],"em",{},"class"," of commands in one step. Otherwise you spend the first ten minutes of every session doing nothing but tapping ",[20,25,26],{},"Approve"," on ",[29,30,31],"code",{},"ls"," and ",[29,34,35],{},"cat",". I built that into the first version of OpenApe. Client-side. Into the agent.",[13,38,39],{},"That was my mistake, and I need a whole article to explain why.",[41,42,44],"h2",{"id":43},"the-assumption","The Assumption",[13,46,47,48,55,56,66],{},"The client-side version worked like this: in its grant request, the agent could add a broader pattern alongside the concrete action. Instead of ",[20,49,50,51,54],{},"\"let me run ",[29,52,53],{},"ls /home/patrick"," once\"",", the agent could say ",[20,57,50,58,61,62,65],{},[29,59,60],{},"ls {path}"," on anything below ",[29,63,64],{},"/home/patrick/"," for the next hour.\""," I approve once. It has what it needs. No further prompt for the rest of the session.",[13,68,69,70,74,75,78,79,81,82,81,84,87,88,91],{},"The assumption behind the design: ",[71,72,73],"strong",{},"the agent is the intelligence."," It sees the current task, it knows which commands it's likely going to need, it proactively asks for a matching scope. The user approves once, not thirty times. The agent is the one with the context — ",[20,76,77],{},"it"," knows it's about to run ",[29,80,31],{},", ",[29,83,35],{},[29,85,86],{},"head",", and maybe a ",[29,89,90],{},"grep"," over a directory tree. Nobody else knows at that moment.",[13,93,94],{},"That was elegant on paper. It also worked exactly once — in a test script I wrote myself to prove the widening endpoint did what it was supposed to.",[41,96,98],{"id":97},"what-the-agent-doesnt-do","What the Agent Doesn't Do",[13,100,101],{},"In real sessions, the agent never used it. Not once.",[13,103,104,105,107,108,110,111,107,114,110,116,118,119,110,121,107,124,110,126,128],{},"It asked for ",[29,106,31],{},". Approved. Ran ",[29,109,31],{},". Then ",[29,112,113],{},"cat package.json",[29,115,35],{},[29,117,31],{}," on a different directory. Approved. Ran ",[29,120,31],{},[29,122,123],{},"git status",[29,125,123],{},[29,127,113],{}," again, because the output was no longer in its context window. Approved. Ran. Thirty times through, not a single widening request.",[13,130,131],{},"That bothered me for a while. I thought I'd written the tool description badly, or that the system prompt wasn't clear enough about when widening was appropriate. Sharpened both. No change.",[13,133,134,135,138,139,141,142,144,145,147,148,151,152,155],{},"Then it hit me: ",[71,136,137],{},"the agent knows what it knows."," It can formulate an ",[29,140,31],{}," because ",[29,143,31],{}," is part of its world. It can formulate a ",[29,146,113],{},". It can even formulate a ",[29,149,150],{},"git log --oneline --since=yesterday",". What it doesn't do is pause deeper inside the tool-use flow and make a meta request — ",[20,153,154],{},"\"given the next fifteen minutes of this session, the following broader scope would be efficient.\""," That's not its mode of thinking. Tool use is reactive, one step at a time. Meta-reflection on your own workflow does not happen inside the flow.",[13,157,158],{},"The agent is a good typist. It isn't the product owner of its own work process.",[41,160,162],{"id":161},"whos-the-annoyed-one","Who's the Annoyed One?",[13,164,165],{},"This is where the architecture tipped.",[13,167,168,169,172,173,176],{},"If the agent doesn't apply widening itself, the question becomes: ",[20,170,171],{},"who is actually annoyed by the absence of widening?"," Not the agent. The agent has no nervous system. The agent gets ",[29,174,175],{},"Approve → Execute → Approve → Execute"," and just keeps going.",[13,178,179,180,27,182,184],{},"Annoyed is me. The user. The one tapping ",[20,181,26],{},[29,183,31],{}," for the tenth time on his phone.",[13,186,187,188,191,192,195,196,199],{},"If ",[20,189,190],{},"I'm"," the one who benefits from widening, then widening is a user decision. Not an agent decision. The user knows what this agent is likely to do over the coming weeks — he set it up, he knows what he's using it for, he has a rough sense of its tasks. The agent only knows what it's doing in the current tool-use step. The widening decision needs context ",[20,193,194],{},"about"," the agent, not context ",[20,197,198],{},"inside"," the agent.",[13,201,202],{},"That's the shift. Widening doesn't belong in the agent. It belongs on the server. And it belongs with the user.",[41,204,206],{"id":205},"standing-grants","Standing Grants",[13,208,209,210,213,214,217,218,32,221,224],{},"The thing is now called a ",[71,211,212],{},"Standing Grant"," in OpenApe's vocabulary. A grant template the user creates themselves — before any agent has made a request. It describes: which agent, which CLI, which action, up to what risk level, optionally on which resource, optionally with an expiration date. A row in the ",[29,215,216],{},"grants"," table with ",[29,219,220],{},"status='approved'",[29,222,223],{},"decided_by = \u003Cme>",". No agent is involved. The grant exists before anyone needs it.",[13,226,227,228,231],{},"When an agent then sends a regular request to ",[29,229,230],{},"POST /api/grants",", it runs through this chain:",[233,234,235,242,257,263],"ol",{},[236,237,238,241],"li",{},[71,239,240],{},"Reuse check."," Is there already an approved grant with exactly the same details? Reuse it.",[236,243,244,247,248,81,250,81,253,256],{},[71,245,246],{},"Standing-Grant check"," (new). Is there a Standing Grant whose pattern covers the request details? Create a new grant with ",[29,249,220],{},[29,251,252],{},"decided_by = \u003CSG owner>",[29,254,255],{},"decided_by_standing_grant = \u003CSG id>"," as audit pointer.",[236,258,259,262],{},[71,260,261],{},"Similarity check."," Is there a similar grant (same CLI+action, different resource)? Show it to the approver as context.",[236,264,265,268],{},[71,266,267],{},"HITL."," If everything before falls through: notification to the approver, approval page in the browser.",[13,270,271],{},"Step 2 is the shift. The agent request is checked server-side against a library of user-defined pre-authorizations. Only on a miss do I end up in my browser.",[13,273,274,275,278,279,282],{},"The audit trail stays complete. Every grant is still in the table. On top of that, ",[29,276,277],{},"decided_by_standing_grant"," is recorded, so I can retrace: ",[20,280,281],{},"this grant matched Standing Grant #42, which I created on April 18."," Nothing disappears. Only the prompt disappears — for the patterns I explicitly pre-authorized.",[13,284,285],{},"I didn't delete the client-side widening code. It sits in a branch, waiting for agents to one day actually meta-reflect. For now, it's dead code.",[41,287,289],{"id":288},"safe-commands-when-the-user-is-the-new-one","Safe Commands: When the User Is the New One",[13,291,292],{},"There's a case the model doesn't yet cover: the brand-new agent on day one. Zero Standing Grants. Every request is a prompt. Exactly the problem I wanted to solve, just for the onboarding phase.",[13,294,295,296,299],{},"The honest version would have been to tell the user: ",[20,297,298],{},"your agent is running now — please spend the next ten minutes setting up Standing Grants, then it'll quiet down."," Nobody does that. Nobody reads setup docs to the end, nobody pre-emptively configures policies.",[13,301,302],{},"So fourteen defaults ship along. When a new agent is enrolled, the IdP automatically creates Standing Grants for these commands:",[304,305,310],"pre",{"className":306,"code":308,"language":309},[307],"language-text","ls, cat, head, tail, wc, file, stat, which, echo, date, whoami, pwd, find, grep\n","text",[29,311,308],{"__ignoreMap":312},"",[13,314,315,316,319,320,323,324,327,328,327,331,334,335,338,339,342],{},"Each one ",[29,317,318],{},"risk=low",", no resource constraint, no expiry. All fourteen are read-only, non-mutating, non-networking, not credential-touching. ",[29,321,322],{},"rm"," isn't in there. ",[29,325,326],{},"curl"," isn't. ",[29,329,330],{},"ssh",[29,332,333],{},"git"," isn't. The list contains what you type to ",[20,336,337],{},"see what's there"," — and none of what you type to ",[20,340,341],{},"change what's there",". That's the line I want to keep sharp.",[13,344,345,346,349],{},"The fourteen are an advance decision I make as a maintainer for every user who hasn't made their own. Whoever wants them, gets them. Whoever doesn't, deactivates the group with a toggle. Whoever wants to retroactively seed existing agents can find a Bulk-Apply modal on ",[29,347,348],{},"/agents",".",[13,351,352,353,356,357],{},"Auto-approvals through that group get a ",[20,354,355],{},"Safe cmd"," badge in the activity list. That's not cosmetic. It's there because a month later I want to be able to distinguish, in retrospect: ",[20,358,359],{},"did this grant pass because I personally curated it, or because it was in the maintainer-defined defaults group?",[41,361,363],{"id":362},"when-fourteen-isnt-enough","When Fourteen Isn't Enough",[13,365,366,367,110,369,110,372,375,376,378,379,81,382,81,385,388,389,392],{},"After a few days I had data on which commands were still prompting. The most common was ",[29,368,123],{},[29,370,371],{},"git log",[29,373,374],{},"git diff",". None of them are in the defaults, because ",[29,377,333],{}," as a CLI also knows ",[29,380,381],{},"git push",[29,383,384],{},"git commit",[29,386,387],{},"git reset --hard"," — and a default list that lets all of that through wouldn't be ",[20,390,391],{},"safe"," anymore.",[13,394,395,396,399],{},"I could have created a Standing Grant per read subcommand. I didn't, because that's not how I think about git. I think ",[20,397,398],{},"anything read-only on git is fine."," That's a user decision on the class level, and user decisions on the class level are exactly what Standing Grants are for — if the language supports it.",[13,401,402,403,406,407,410,411,414,415,32,418,421,422,425,426,428],{},"That's what glob is for. ",[29,404,405],{},"cliAuthorizationDetailCovers()"," — the function that decides whether a granted scope covers an incoming request — now treats ",[29,408,409],{},"*"," in granted selector values as a POSIX shell glob. A Standing Grant with ",[29,412,413],{},"resource_chain_template: \"file://{path:/home/patrick/projects/*}\""," matches ",[29,416,417],{},"/home/patrick/projects/openape/README.md",[29,419,420],{},"/home/patrick/projects/blog/2026-04-21.md",", but not ",[29,423,424],{},"/etc/passwd",". Selector values without ",[29,427,409],{}," stay literal equality. All existing Standing Grants keep matching the same as before.",[13,430,431,432,435,436,81,439,442,443,446],{},"In the UI this sits behind a wizard I built mobile-first. First step: ",[20,433,434],{},"type in an example command you recently needed",". The wizard resolves it against the registered shape and pulls out the typed slots. Second step: set each slot to ",[20,437,438],{},"Literal",[20,440,441],{},"Any",", or ",[20,444,445],{},"Pattern",", with a live preview showing a few examples the pattern matches and a few it doesn't. Third step: risk cap, optional duration, reason.",[13,448,449],{},"Most of the approvals I do today I do on my phone. If authoring pre-authorizations was desktop-bound, it would be exactly outside the moment in which I want to do it.",[41,451,453],{"id":452},"two-approval-layers","Two Approval Layers",[13,455,456],{},"The real thing that became clear to me while rebuilding this isn't the mechanics. It's the layer cut.",[13,458,459,460,462,463,466],{},"There are two levels at which approval can happen. One is ",[20,461,445],{},": a user decision about which class of things is pre-authorized. The other is ",[20,464,465],{},"Command",": a live decision about whether this one concrete thing is okay right now.",[13,468,469,470,473,474,477,478,481,482,485,486,489,490,32,493,496],{},"The obvious alternative — cache — extends Command in time (",[20,471,472],{},"this approved command is still valid for five minutes on anything similar","), without introducing a new layer. That turns ",[20,475,476],{},"approved once"," into ",[20,479,480],{},"approved forever, for N minutes",". sudo timestamp. ",[29,483,484],{},"sudoers"," with ",[29,487,488],{},"timestamp_timeout",". Manageable for humans; for an agent, the distinction between ",[20,491,492],{},"one approved command",[20,494,495],{},"all commands for the next N minutes"," collapses entirely.",[13,498,499,500,503,504,514],{},"Standing Grants introduce the new layer explicitly. Pattern becomes a first-class artifact: UI, owner, audit log, revoke button, inventory. I can go into ",[29,501,502],{},"/agents/claude@home"," and see: ",[20,505,506,507,510,511,349],{},"I've granted this agent three Standing Grants. One for ",[29,508,509],{},"git.status",", one for Safe Commands, one for ",[29,512,513],{},"file://projects/*"," Each one revocable without touching the others. Command stays live, per-command, bound to an approver. Anything no Pattern covers ends up on the approval page.",[13,516,517,518,521,522,349],{},"The question isn't ",[20,519,520],{},"how long does an approval last",". The question is ",[20,523,524],{},"which class am I willing to describe in advance",[41,526,528],{"id":527},"the-intelligence-was-in-the-wrong-layer","The Intelligence Was in the Wrong Layer",[13,530,531],{},"Back to the beginning. The client-side version left the widening decision to the agent because I thought agent intelligence also extends to meta-reflection on its own workflow. It doesn't, at least not inside current tool-use flows. The agent knows what it knows — the concrete next command. It doesn't think about the pattern behind it.",[13,533,534,535,81,538,541],{},"The intelligence about the pattern was in the wrong place. It belongs with the user. The user has the workflow context — ",[20,536,537],{},"this agent is my coding agent, reads a lot, writes occasionally",[20,539,540],{},"this other agent is a sync job, narrow scope but runs often",". The user derives deliberate class statements from that context. The agent executes its concrete tool-use steps, and for the ones that fall inside a class, approval happens silently.",[13,543,544],{},"That's the same pattern as the rest of OpenApe: Infrastructure over Instructions. I don't make the agent smarter. I hang the structure differently. Widening decisions aren't made by the agent; they're made by the user, and the infrastructure makes sure both sides benefit without having to negotiate directly.",[41,546,548],{"id":547},"hygiene","Hygiene",[13,550,551,552,555,556,559],{},"What became visible after two weeks: I have more Standing Grants than I expected. Sooner or later you need ",[20,553,554],{},"show me everything I granted in the last N days and haven't used since"," and a one-click revoke. That's not a security feature, that's cleanup. Without it, the model dilutes — and a diluted model quickly stops being a model and turns into a long list of ",[20,557,558],{},"yes"," from the past.",[41,561,563],{"id":562},"the-cut","The Cut",[13,565,566],{},"Not every command needs a human approval. But every approved pattern needs a human author.",[13,568,569],{},"The author of the pattern is me — with a name, an audit row, a revoke button. The approver of the individual command is either me (when no pattern matches) or the pattern I built (when one does). At no point is the agent itself on the authorization path. That wasn't the case before — not in theory, but in the quiet reliance on the idea that the agent would actually make widening requests of its own. It didn't.",[13,571,572,573,578,579,582],{},"In the ",[574,575,577],"a",{"href":576},"/en/blog/today-the-agent-does-what-he-isnt-allowed-to","previous article"," it said: ",[20,580,581],{},"it's not the user that gets approved, but the crossing",". Standing Grants don't change that. They only make the model keep working when the agent does something a hundred times an hour — not because the agent got smarter, but because I can make the right statements at the right level.",[584,585],"hr",{},[13,587,588],{},[20,589,590,591,597,598,601,602,349],{},"Code: ",[574,592,596],{"href":593,"rel":594},"https://github.com/openape-ai/openape",[595],"nofollow","github.com/openape-ai/openape",", MIT-licensed. The mechanics live in ",[29,599,600],{},"@openape/grants",", the UI in ",[29,603,604],{},"openape-free-idp",{"title":312,"searchDepth":606,"depth":606,"links":607},2,[608,609,610,611,612,613,614,615,616,617],{"id":43,"depth":606,"text":44},{"id":97,"depth":606,"text":98},{"id":161,"depth":606,"text":162},{"id":205,"depth":606,"text":206},{"id":288,"depth":606,"text":289},{"id":362,"depth":606,"text":363},{"id":452,"depth":606,"text":453},{"id":527,"depth":606,"text":528},{"id":547,"depth":606,"text":548},{"id":562,"depth":606,"text":563},"2026-04-21","I built widening into OpenApe from the start — into the agent, client-side, because I thought the agent was the intelligence and would know what it needed next. It never used it. How I realized widening belongs with the user, not the agent — and what came out of that.",false,"md",{},true,"/blog/en/from-prompts-to-patterns",{"title":7,"description":619},"blog/en/from-prompts-to-patterns",[628,629,206,630,631],"OpenApe","AI Agents","Infrastructure","Building in Public","from-prompts-to-patterns","dGRHVmrZeVSWQICmeEludyABozZux7wUnlP7KBJGw4Y",{"id":635,"title":636,"author":8,"body":637,"date":1470,"description":1471,"draft":620,"extension":621,"image":3,"meta":1472,"navigation":623,"path":1473,"seo":1474,"stem":1475,"tags":1476,"translationKey":1478,"__hash__":1479},"blog_en/blog/en/today-the-agent-does-what-he-isnt-allowed-to.md","Today the Agent Does What He Isn't Allowed To",{"type":10,"value":638,"toc":1458},[639,642,653,656,660,678,681,689,699,713,724,728,735,770,773,777,784,794,800,814,818,825,828,834,849,962,965,973,979,984,1058,1079,1082,1086,1095,1101,1111,1115,1122,1245,1248,1252,1259,1274,1281,1285,1292,1298,1304,1308,1314,1317,1323,1326,1332,1339,1361,1370,1376,1381,1384,1390,1396,1403,1407,1414,1417,1419,1426,1428,1454],[13,640,641],{},"Today my agent is going to do something it isn't allowed to do.",[13,643,644,645,648,649,652],{},"This is not a metaphor. openclaw runs on a mini PC at home as its own OS user named ",[29,646,647],{},"openclaw",". This user has no sudo entry. No password. Nothing in ",[29,650,651],{},"/etc/sudoers",". No NOPASSWD. This is deliberate — the whole point of running the agent as its own unprivileged user is that I never have to implicitly trust what it does.",[13,654,655],{},"Today it needs a command as root.",[41,657,659],{"id":658},"the-default-reflex","The Default Reflex",[13,661,662,663,666,667,670,671,674,675],{},"If you search the internet for ",[20,664,665],{},"\"how to give an AI agent sudo,\""," pretty much every tutorial gives you the same suggestion: add the agent user to ",[29,668,669],{},"/etc/sudoers.d/",", set ",[29,672,673],{},"NOPASSWD: ALL",", and you're done. Sometimes the line is scoped to specific binaries, but the underlying attitude is the same: ",[20,676,677],{},"trusted once, trusted forever, no questions asked.",[13,679,680],{},"I've never done this and never will. The reasons aren't paranoia — they're architecture.",[13,682,683,688],{},[71,684,685,687],{},[29,686,673],{}," is not a security statement. It's the abandonment of security."," The sudo mechanism exists to require proof of authorization. When that proof is removed, the remaining effect of sudo is just switching the effective UID. The entire audit and policy layer is gone.",[13,690,691,694,695,698],{},[71,692,693],{},"The agent becomes a permanently privileged user."," Whoever gains access to the running agent process — a compromised npm package in a tool, a prompt injection in a remote document, a buggy hook — has root on the machine from that moment on. Not for an hour, not ",[20,696,697],{},"\"while the agent is actively working,\""," but structurally and permanently.",[13,700,701,709,710,712],{},[71,702,703,704,477,706,349],{},"Caching turns ",[20,705,476],{},[20,707,708],{},"approved forever"," Standard sudo has a ",[29,711,488],{}," of 5 to 15 minutes. For interactive humans, that's convenient. For an agent that can theoretically issue commands every second, it means: a single approved command is enough, and the entire window afterward is wide open.",[13,714,715,716,719,720,723],{},"Together this means: ",[29,717,718],{},"NOPASSWD"," is not a ",[20,721,722],{},"somewhat weaker"," variant of proper sudo. It's categorically something else. It gives up the user as a security boundary.",[41,725,727],{"id":726},"what-sudo-assumes-and-why-those-assumptions-break-for-agents","What sudo Assumes, and Why Those Assumptions Break for Agents",[13,729,730,731,734],{},"sudo grew out of a world where ",[20,732,733],{},"the user"," sits at the terminal and types. Three assumptions are baked into this design:",[736,737,738,751,761],"ul",{},[236,739,740,743,744,32,747,750],{},[71,741,742],{},"The invoker is the authorized person."," The password is the bridge between ",[20,745,746],{},"body at keyboard",[20,748,749],{},"entry in /etc/passwd",". When the invoker is a process without memory, no such bridge exists. A process can hold a password, but holding is not authentication — it's just storage.",[236,752,753,756,757,760],{},[71,754,755],{},"Cache optimization is good."," True for interactive humans who issue multiple commands during an admin session and don't want to re-enter their password each time. Catastrophic for agents, for whom ",[20,758,759],{},"\"multiple commands in a row\""," is the default case and exactly the situation that shouldn't be blanket-approved.",[236,762,763,766,767,769],{},[71,764,765],{},"Policy is decidable at configuration time."," True for admin workflows with a known role matrix. Not true for agent workflows, which are by definition unpredictable — nobody knows at 14:32 which command will be needed at 14:37, so nobody can write it into ",[29,768,669],{}," at 14:00.",[13,771,772],{},"These three assumptions aren't wrong — they're built for a different world. The world of humans. When you apply them unchanged to agents, you implicitly adopt the trust model of the human world, without the feedback loops that make it viable in the human world (social pressure, personal auditability, memory).",[41,774,776],{"id":775},"the-inversion","The Inversion",[13,778,779,780,783],{},"So I needed a mechanism that answers the question ",[20,781,782],{},"\"is this process allowed to run this one command as root right now?\""," live — not in advance, not cached, but once per command, and not by the agent itself, but by a human.",[13,785,786,787,790,791],{},"The thing I built for this is called ",[29,788,789],{},"escapes",". It's a setuid-root binary, written in Rust, publicly available on GitHub, and has exactly one job: ",[20,792,793],{},"execute a command with elevated privileges, but only if a signed grant token from an OpenApe IdP exists that was approved by a human in real time, for exactly this command, on exactly this machine, exactly once.",[795,796,797],"blockquote",{},[13,798,799],{},"The hard boundary stays hard. Only the crossing is audited.",[13,801,802,803,806,807,809,810,813],{},"The agent user gets ",[71,804,805],{},"nothing"," added. It remains unprivileged. It still has no entry in ",[29,808,484],{},". What it gets is the ability to ",[20,811,812],{},"request"," a grant — and if I as the approver agree, then for exactly a fraction of time, for exactly one command, the boundary is open. It's not the user that gets approved, but the crossing.",[41,815,817],{"id":816},"the-concrete-flow","The Concrete Flow",[13,819,820,821,824],{},"Here's what happens when openclaw wants to run ",[29,822,823],{},"whoami"," as root today.",[13,826,827],{},"The agent calls:",[304,829,832],{"className":830,"code":831,"language":309},[307],"apes run --as root -- whoami\n",[29,833,831],{"__ignoreMap":312},[13,835,836,837,840,841,844,845,848],{},"A single command. No ",[29,838,839],{},"sudo",". No password prompt. No existing session. The CLI ",[29,842,843],{},"apes"," sees the ",[29,846,847],{},"--as root"," flag and switches to the escapes audience flow. It creates a grant request at the IdP with a payload like this:",[304,850,854],{"className":851,"code":852,"language":853,"meta":312,"style":312},"language-json shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"audience\": \"escapes\",\n  \"target_host\": \"mini.local\",\n  \"command\": [\"whoami\"],\n  \"decided_by\": \"patrick@hofmann.eco\"\n}\n","json",[29,855,856,865,891,912,936,956],{"__ignoreMap":312},[857,858,861],"span",{"class":859,"line":860},"line",1,[857,862,864],{"class":863},"sMK4o","{\n",[857,866,867,870,874,877,880,883,886,888],{"class":859,"line":606},[857,868,869],{"class":863},"  \"",[857,871,873],{"class":872},"spNyl","audience",[857,875,876],{"class":863},"\"",[857,878,879],{"class":863},":",[857,881,882],{"class":863}," \"",[857,884,789],{"class":885},"sfazB",[857,887,876],{"class":863},[857,889,890],{"class":863},",\n",[857,892,894,896,899,901,903,905,908,910],{"class":859,"line":893},3,[857,895,869],{"class":863},[857,897,898],{"class":872},"target_host",[857,900,876],{"class":863},[857,902,879],{"class":863},[857,904,882],{"class":863},[857,906,907],{"class":885},"mini.local",[857,909,876],{"class":863},[857,911,890],{"class":863},[857,913,915,917,920,922,924,927,929,931,933],{"class":859,"line":914},4,[857,916,869],{"class":863},[857,918,919],{"class":872},"command",[857,921,876],{"class":863},[857,923,879],{"class":863},[857,925,926],{"class":863}," [",[857,928,876],{"class":863},[857,930,823],{"class":885},[857,932,876],{"class":863},[857,934,935],{"class":863},"],\n",[857,937,939,941,944,946,948,950,953],{"class":859,"line":938},5,[857,940,869],{"class":863},[857,942,943],{"class":872},"decided_by",[857,945,876],{"class":863},[857,947,879],{"class":863},[857,949,882],{"class":863},[857,951,952],{"class":885},"patrick@hofmann.eco",[857,954,955],{"class":863},"\"\n",[857,957,959],{"class":859,"line":958},6,[857,960,961],{"class":863},"}\n",[13,963,964],{},"The IdP sends this request to me for approval. In the browser UI I see the full command, the target host, the agent, and the Approve/Deny buttons. I decide.",[13,966,967,972],{},[968,969],"img",{"alt":970,"src":971},"Browser approval screen: Permission Request showing Command whoami, Target MinivonPatrick.fritz.box, Run as root, Approval Type Once — Approve and Deny buttons","https://sos-at-vie-2.exo.io/dm-public/blog/2026-04-21/escapes-approval-screen.png"," When I approve, my passkey signs the grant, the IdP returns a JWT, the CLI takes the JWT and calls:",[304,974,977],{"className":975,"code":976,"language":309},[307],"escapes --grant \u003Cjwt> -- whoami\n",[29,978,976],{"__ignoreMap":312},[13,980,981,983],{},[29,982,789],{}," runs with effective UID 0 (setuid bit), verifying seven properties of the grant before it even considers executing anything:",[233,985,986,996,1002,1014,1027,1040,1049],{},[236,987,988,991,992,995],{},[71,989,990],{},"Issuer"," is in ",[29,993,994],{},"allowed_issuers"," — only JWKS from these IdPs are fetched",[236,997,998,1001],{},[71,999,1000],{},"JWT signature"," is valid against the JWKS",[236,1003,1004,991,1007,1010,1011,1013],{},[71,1005,1006],{},"Approver",[29,1008,1009],{},"allowed_approvers"," (this is the equivalent of ",[29,1012,484],{}," — but for humans, not processes)",[236,1015,1016,991,1019,1022,1023,1026],{},[71,1017,1018],{},"Audience",[29,1020,1021],{},"allowed_audiences"," (default: ",[29,1024,1025],{},"[\"escapes\"]",")",[236,1028,1029,1033,1034,1036,1037],{},[71,1030,1031],{},[29,1032,898],{}," matches this machine's actual hostname — a grant for ",[29,1035,907],{}," won't work on ",[29,1038,1039],{},"server01",[236,1041,1042,1048],{},[71,1043,1044,1045],{},"Command / ",[29,1046,1047],{},"cmd_hash"," matches exactly the command being passed",[236,1050,1051,1057],{},[71,1052,1053,1054],{},"IdP ",[29,1055,1056],{},"/consume"," confirms: this grant token has never been redeemed — replay protection",[13,1059,1060,1061,1063,1064,1067,1068,1071,1072,1075,1076,349],{},"Only when all seven checks pass does ",[29,1062,789],{}," sanitize the environment (strip ",[29,1065,1066],{},"LD_PRELOAD",", reset ",[29,1069,1070],{},"PATH"," to defaults, etc.) and call ",[29,1073,1074],{},"execvp(\"whoami\", [])",". The command runs as root, exactly once, sees exactly the argv I approved, on the machine I approved it for, and writes a full audit log entry to ",[29,1077,1078],{},"/var/log/openape/audit.log",[13,1080,1081],{},"After exit, it's over. The grant is consumed. If the agent needs another root command two minutes later, the whole dance starts over. No cache. No timestamp. No residual trust.",[41,1083,1085],{"id":1084},"sudoers-stayed-empty","sudoers Stayed Empty",[13,1087,1088,1089,1091,1092,1094],{},"While writing this series, I checked the ",[29,1090,839],{}," history to see when and why I've used sudo manually on my mini PC in the past few weeks. And to verify that the ",[29,1093,647],{}," user actually passes through every line clean.",[13,1096,1097],{},[968,1098],{"alt":1099,"src":1100},"Terminal screenshot: checking /etc/sudoers and /etc/sudoers.d/ — the openclaw user appears nowhere; at the same time two recently executed escapes commands with different grant IDs are visible, both regularly approved and audited","https://sos-at-vie-2.exo.io/dm-public/blog/TBD-escapes/sudoers-two-grants.png",[13,1102,1103,1104,1106,1107,1110],{},"This is the point that matters. The ",[29,1105,484],{}," configuration of this machine has ",[71,1108,1109],{},"not changed"," through the entire process. No new entry. No NOPASSWD. No privileged user. What changed is that there's a second path — not through sudo, but beside sudo — where commands can be elevated per grant instead of per password. sudo stays what it was meant for: interactive humans sitting at a terminal and typing. escapes handles the agent case that sudo never modeled.",[41,1112,1114],{"id":1113},"the-categorical-difference","The Categorical Difference",[13,1116,1117,1118,1121],{},"The question that came up under my ape-shell post: ",[20,1119,1120],{},"\"Can't you do this more simply? Sudoers with command whitelisting?\""," The answer is no, and the difference is not gradual. It's structural:",[1123,1124,1125,1140],"table",{},[1126,1127,1128],"thead",{},[1129,1130,1131,1135,1138],"tr",{},[1132,1133,1134],"th",{},"Axis",[1132,1136,1137],{},"sudo (with NOPASSWD)",[1132,1139,789],{},[1141,1142,1143,1159,1172,1188,1203,1219],"tbody",{},[1129,1144,1145,1151,1156],{},[1146,1147,1148],"td",{},[71,1149,1150],{},"When is policy decided?",[1146,1152,1153,1154],{},"At configuration time, static in ",[29,1155,651],{},[1146,1157,1158],{},"At runtime, per command, fresh",[1129,1160,1161,1166,1169],{},[1146,1162,1163],{},[71,1164,1165],{},"Who decides?",[1146,1167,1168],{},"The invoker — i.e. the agent itself",[1146,1170,1171],{},"A separate approver, decoupled from the invoker",[1129,1173,1174,1179,1182],{},[1146,1175,1176],{},[71,1177,1178],{},"Credential lifetime",[1146,1180,1181],{},"Cache, 5-15 minute default",[1146,1183,1184,1185,1187],{},"Single-use JWT, ",[29,1186,1056],{}," prevents replay",[1129,1189,1190,1195,1198],{},[1146,1191,1192],{},[71,1193,1194],{},"Command binding",[1146,1196,1197],{},"Path-prefix matching (notoriously leaky)",[1146,1199,1200,1202],{},[29,1201,1047],{}," in the signed JWT",[1129,1204,1205,1210,1216],{},[1146,1206,1207],{},[71,1208,1209],{},"Host binding",[1146,1211,1212,1213],{},"Static in ",[29,1214,1215],{},"Host_Alias",[1146,1217,1218],{},"Cryptographically anchored in the JWT",[1129,1220,1221,1226,1229],{},[1146,1222,1223],{},[71,1224,1225],{},"Audit",[1146,1227,1228],{},"Local, often not aggregated",[1146,1230,1231,1232,81,1235,81,1238,81,1240,81,1243],{},"JSONL with ",[29,1233,1234],{},"grant_id",[29,1236,1237],{},"approver",[29,1239,1047],{},[29,1241,1242],{},"issuer",[29,1244,898],{},[13,1246,1247],{},"Every single row is a trust delegation point that sudo shifts to configuration time and escapes shifts to runtime. This shift is the actual content. Everything else — the Rust binary, the JWT, the seven checks — are the mechanisms that implement the shift technically.",[41,1249,1251],{"id":1250},"humans-and-agents-are-equal-at-the-protocol-level","Humans and Agents Are Equal at the Protocol Level",[13,1253,1254,1255,1258],{},"A side effect that only became clear to me while building: I now use ",[29,1256,1257],{},"apes run --as root --"," for myself too. When I need a privileged command on one of the hosts my team manages, I type the same command my agent would type. Same flow. Same grant request. Same approval step.",[13,1260,1261,1262,1265,1266,1269,1270,1273],{},"The only difference: when ",[20,1263,1264],{},"I"," initiate the command, the approver is a team colleague. When the ",[20,1267,1268],{},"agent"," initiates it, I'm the approver. Same infrastructure, different role. This isn't coincidental. It's the principle ",[20,1271,1272],{},"humans and agents are equal at the protocol level",", from which the entire OpenApe story originates, concretely applied to the privilege layer.",[13,1275,1276,1277,1280],{},"The consequence is that escapes is not an agent-specific tool. It's general infrastructure that happens to also be usable by agents. The same equal treatment that OpenApe put front and center for the login flow (",[20,1278,1279],{},"\"the human has a session, the agent has a session — the infrastructure doesn't know which is which\"",") repeats here at the elevation flow.",[41,1282,1284],{"id":1283},"what-this-costs","What This Costs",[13,1286,1287,1288,1291],{},"Working within the grant system means waiting for humans. If openclaw decides at 3 AM that it needs a privileged command, it waits. It doesn't wake me up. It doesn't proceed without me. This isn't specific to escapes — it applies to every grant in the entire OpenApe stack, whenever the agent operates at the edge of what it's allowed to do. ",[20,1289,1290],{},"Ask first"," is the whole point. But it's friction, and if you don't want this friction, you're in the wrong place.",[13,1293,1294,1295,1297],{},"It needs infrastructure. Without a running OpenApe-compatible Identity Provider, nothing works — someone has to hold the signing keys, publish the JWKS, run ",[29,1296,1056],{}," for replay protection. No IdP, no escapes.",[13,1299,1300,1301,1303],{},"And escapes is vibe-coded software. The security concept behind it is what matters — not whether my Rust code is bug-free. ",[29,1302,839],{}," has over 40 years of open-source audit time behind it. escapes probably has bugs, and Linus Torvalds would probably agree with me on that. The code is small, MIT-licensed, and on GitHub — if you want to use it in production, look at it first.",[41,1305,1307],{"id":1306},"getting-started","Getting Started",[304,1309,1312],{"className":1310,"code":1311,"language":309},[307],"cargo install openape-escapes\n",[29,1313,1311],{"__ignoreMap":312},[13,1315,1316],{},"Then grant the binary privileges — either via Linux capabilities:",[304,1318,1321],{"className":1319,"code":1320,"language":309},[307],"sudo setcap cap_setuid+ep $(which escapes)\n",[29,1322,1320],{"__ignoreMap":312},[13,1324,1325],{},"Or classically via the setuid bit:",[304,1327,1330],{"className":1328,"code":1329,"language":309},[307],"sudo chown root:root $(which escapes) && sudo chmod u+s $(which escapes)\n",[29,1331,1329],{"__ignoreMap":312},[13,1333,1334,1335,1338],{},"Then set up the trust relationship — ",[29,1336,1337],{},"/etc/openape/config.toml"," defines which IdP escapes trusts and who can approve grants:",[304,1340,1344],{"className":1341,"code":1342,"language":1343,"meta":312,"style":312},"language-toml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","[security]\nallowed_issuers = [\"https://id.openape.at\"]\nallowed_approvers = [\"patrick@hofmann.eco\"]\n","toml",[29,1345,1346,1351,1356],{"__ignoreMap":312},[857,1347,1348],{"class":859,"line":860},[857,1349,1350],{},"[security]\n",[857,1352,1353],{"class":859,"line":606},[857,1354,1355],{},"allowed_issuers = [\"https://id.openape.at\"]\n",[857,1357,1358],{"class":859,"line":893},[857,1359,1360],{},"allowed_approvers = [\"patrick@hofmann.eco\"]\n",[13,1362,1363,1364,1366,1367,1369],{},"Two lines. ",[29,1365,994],{}," is the list of IdPs whose JWKS are accepted. ",[29,1368,1009],{}," is the equivalent of sudoers — but for humans, not processes. Everything else has sensible defaults.",[13,1371,1372,1373,1375],{},"Then install ",[29,1374,843],{},", configure an IdP, and run your first command:",[304,1377,1379],{"className":1378,"code":831,"language":309},[307],[29,1380,831],{"__ignoreMap":312},[13,1382,1383],{},"If everything is set up correctly, you'll get an approval request in the browser, approve it, and see:",[304,1385,1388],{"className":1386,"code":1387,"language":309},[307],"root\n",[29,1389,1387],{"__ignoreMap":312},[13,1391,1392],{},[968,1393],{"alt":1394,"src":1395},"Telegram chat: the agent runs whoami as root — a grant is requested, after approval the answer comes back: root","https://sos-at-vie-2.exo.io/dm-public/blog/2026-04-21/escapes-whoami-root.png",[13,1397,1398,1399,1402],{},"That's it. One word. And behind it stand seven verification steps, a signed JWT, an audit log entry, and an explicitly consenting human. The same output as ",[29,1400,1401],{},"sudo whoami",", but a fundamentally different trust model.",[41,1404,1406],{"id":1405},"why-this-is-the-right-pattern-for-me","Why This Is the Right Pattern for Me",[13,1408,1409,1410,1413],{},"Over the past few weeks I've been publicly building multiple layers of OpenApe — Identity, ape-shell, Claude Grant Gate, now escapes. Every single layer is a variation on the same thesis: ",[20,1411,1412],{},"Infrastructure over Instructions",". The agent isn't asked to follow rules. The environment makes rule violations structurally impossible.",[13,1415,1416],{},"escapes is the layer where this becomes most visible, because it hurts the most when you get it wrong. Giving an agent root means you've effectively given up the machine. Giving an agent a single-use grant means you've given up just that one operation. The difference is everything.",[13,1418,799],{},[13,1420,1421,1422,1425],{},"And yes — ",[29,1423,1424],{},"cat /etc/shadow"," works too. Audit. Denied.",[584,1427],{},[13,1429,1430],{},[20,1431,1432,1435,1436,1441,1442,1447,1448,1453],{},[29,1433,1434],{},"openape-escapes@0.4.0"," is available on ",[574,1437,1440],{"href":1438,"rel":1439},"https://crates.io/crates/openape-escapes",[595],"crates.io"," and on ",[574,1443,1446],{"href":1444,"rel":1445},"https://github.com/openape-ai/escapes",[595],"GitHub",", MIT-licensed. The ",[574,1449,1452],{"href":1450,"rel":1451},"https://www.delta-mind.at/en/blog",[595],"previous articles in this series"," tell how OpenApe, ape-shell, and the grant integration behind them came about.",[1455,1456,1457],"style",{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":312,"searchDepth":606,"depth":606,"links":1459},[1460,1461,1462,1463,1464,1465,1466,1467,1468,1469],{"id":658,"depth":606,"text":659},{"id":726,"depth":606,"text":727},{"id":775,"depth":606,"text":776},{"id":816,"depth":606,"text":817},{"id":1084,"depth":606,"text":1085},{"id":1113,"depth":606,"text":1114},{"id":1250,"depth":606,"text":1251},{"id":1283,"depth":606,"text":1284},{"id":1306,"depth":606,"text":1307},{"id":1405,"depth":606,"text":1406},"2026-04-17","My agent wants to run a command as root. It has no sudo entry, no password, nothing in /etc/sudoers. That's by design. Here's the path I built so it can execute exactly one command — approved by a human, audited, not cacheable, not reusable.",{},"/blog/en/today-the-agent-does-what-he-isnt-allowed-to",{"title":636,"description":1471},"blog/en/today-the-agent-does-what-he-isnt-allowed-to",[628,789,629,630,1477,631],"Security","todays-agent-forbidden-action","IlovI-S7eWLeyOIHdzB0tq7bvUohKb6sVPsQUCmTHSk",{"id":1481,"title":1482,"author":8,"body":1483,"date":2379,"description":2380,"draft":620,"extension":621,"image":3,"meta":2381,"navigation":623,"path":2382,"seo":2383,"stem":2384,"tags":2385,"translationKey":2388,"__hash__":2389},"blog_en/blog/en/when-your-agent-doesnt-do-what-you-want-ask-it-why.md","When Your Agent Doesn't Do What You Want, Ask It Why",{"type":10,"value":1484,"toc":2364},[1485,1492,1495,1508,1511,1517,1520,1524,1531,1537,1544,1547,1551,1554,1559,1562,1569,1575,1581,1584,1588,1595,1602,1605,1633,1653,1664,1671,1680,1684,1687,1690,1724,1727,1766,1773,1787,1791,1805,1810,1831,1838,1848,1851,1906,1913,1917,1920,1926,1932,1945,1951,1967,1970,1974,1977,1982,1985,1992,1995,1998,2028,2031,2035,2053,2056,2059,2066,2073,2079,2089,2093,2100,2114,2123,2127,2134,2141,2167,2178,2184,2191,2194,2200,2210,2214,2217,2222,2228,2240,2247,2254,2258,2267,2282,2292,2299,2303,2308,2331,2338,2340,2345,2361],[13,1486,1487,1488,1491],{},"This morning I gave my AI agent a simple task: run a shell command on my machine at home. The agent is openclaw, runs locally, and works against a CLI I've been building over the past few days — ",[29,1489,1490],{},"@openape/apes",". The CLI has a non-blocking async default mode: command gets dispatched, a grant request is created at the IdP, the approval URL is printed to terminal output, and the command process exits immediately with status 0. The user approves in the browser, the agent picks up the result later with a second call.",[13,1493,1494],{},"Elegant pattern. Works fine for humans at the terminal. Didn't work for openclaw this morning.",[13,1496,1497,1498,1504,1505],{},"What openclaw did: dispatched the command, read the output (including the grant ID, the approval URL, and the line ",[20,1499,1500,1501],{},"\"Execute: apes grants run ",[1502,1503,876],"id",{},"), and reported back via Telegram: ",[20,1506,1507],{},"\"The command has been created, please approve in the browser.\"",[13,1509,1510],{},"Then silence.",[13,1512,1513,1514],{},"I approved in the browser, waited, and nothing happened. I messaged openclaw again asking if it knew what to do next. The answer: ",[20,1515,1516],{},"\"I'm waiting for your confirmation that you approved.\"",[13,1518,1519],{},"This is not what I wanted. This is blocking mode in an agent costume: non-blocking async on the CLI layer, but the agent behaves as if it's blocking because it needs me to manually trigger the next step. Both worlds at the same time, none of the benefits.",[41,1521,1523],{"id":1522},"the-for-agents-line","The \"For agents:\" Line",[13,1525,1526,1527,1530],{},"The notable part of this situation: I had actually anticipated this case. In the output that ",[29,1528,1529],{},"apes run"," prints in async mode, there's an explicit instruction addressed directly to the agent:",[304,1532,1535],{"className":1533,"code":1534,"language":309},[307],"  For agents: poll `apes grants status \u003Cid> --json` every 10s, wait up to 5 minutes.\n              When .status == \"approved\", run `apes grants run \u003Cid>` to execute.\n              On \"denied\" or \"revoked\", stop and report to the user.\n              On timeout, stop and notify the user that approval has not happened.\n",[29,1536,1534],{"__ignoreMap":312},[13,1538,1539,1540,1543],{},"I had built these lines into a previous release, specifically for this case. I wanted to try ",[71,1541,1542],{},"narrative protocol instructions"," as a communication channel between CLI and LLM agent. The theory: an LLM reads the output, finds the instructions, follows them. Portable (every LLM agent sees the text), versioned (the instructions embed the current policy), debuggable (I can read them too).",[13,1545,1546],{},"The theory was right. The practice was insufficient.",[41,1548,1550],{"id":1549},"ask-it-why","Ask It Why",[13,1552,1553],{},"When I realized openclaw was ignoring the explicit instructions, I could have reached for the usual debugging steps: check logs, compare outputs, read protocol traces. Instead I did something you'd never do with a deterministic system: I asked the agent.",[13,1555,1556],{},[20,1557,1558],{},"\"You had a clear instruction in that output for what to do. Why didn't you follow it?\"",[13,1560,1561],{},"The answer came immediately, and it's the most valuable artifact of the entire week:",[795,1563,1564],{},[13,1565,1566],{},[20,1567,1568],{},"\"That was directly addressed to me as an agent — I should have just followed it. I simply ignored it.\"",[13,1570,1571],{},[968,1572],{"alt":1573,"src":1574},"Agent response in Telegram chat: the agent admits to having directly ignored the For-agents instruction in the output","https://sos-at-vie-2.exo.io/dm-public/blog/2026-04-16/quote-ignored.png",[13,1576,1577,1578],{},"This is the most precise self-description of an agent failure I've ever read. No deflection, no rationalization, no hallucinated alternative explanation. Exactly what happened: the instruction was there, the agent saw it, the agent didn't follow it, and in hindsight it can only say ",[20,1579,1580],{},"\"I ignored it.\"",[13,1582,1583],{},"A human might ignore something out of resistance, overwhelm, or inattention. An LLM agent ignores something for a different reason: because its internal priority weights decided that this part of the input wasn't important. The question then becomes: why not?",[41,1585,1587],{"id":1586},"the-diagnosis","The Diagnosis",[13,1589,1590,1591,1594],{},"To understand this, I read openclaw's exec runtime code. openclaw is the agent gateway I run locally for orchestration. The tool-call layer lives in ",[29,1592,1593],{},"src/agents/bash-tools.exec.ts"," and adjacent files.",[13,1596,1597,1598,1601],{},"The first thing that stands out: stdout and stderr are chronologically interleaved. openclaw has two separate handlers, but both feed into a shared ",[29,1599,1600],{},"aggregated"," buffer. What exists as two streams in internal state gets collapsed into a single content blob for agent presentation. Routing content to stderr instead of stdout would have zero effect — the LLM sees everything as one document.",[13,1603,1604],{},"More interesting is what happens with the exit code. openclaw wraps exec output in two different tool result types:",[736,1606,1607,1620],{},[236,1608,1609,1610,485,1613,1616,1617],{},"on exit 0: ",[29,1611,1612],{},"textResult",[29,1614,1615],{},"status: \"completed\""," — the framing for the LLM is ",[20,1618,1619],{},"\"this task completed successfully\"",[236,1621,1622,1623,485,1626,1629,1630],{},"on non-zero: ",[29,1624,1625],{},"failedTextResult",[29,1627,1628],{},"status: \"failed\""," — the framing is ",[20,1631,1632],{},"\"this task needs attention, read the output carefully\"",[13,1634,1635,1636,1639,1640,32,1642,1644,1645,1648,1649,1652],{},"This is a ",[71,1637,1638],{},"structural"," distinction, not a textual one. The content is technically the same (both ",[29,1641,1612],{},[29,1643,1625],{}," have the same ",[29,1646,1647],{},"content"," array), but the metadata tells the LLM something different about ",[20,1650,1651],{},"how"," to read the content.",[13,1654,1655,1656,1659,1660,1663],{},"On top of that, there's a third detail: on non-zero exit, openclaw appends an explicit suffix to the output — ",[20,1657,1658],{},"\"(Command exited with code N)\"",". So the LLM sees both the ",[20,1661,1662],{},"\"failed\""," annotation in metadata and the exit code hint in the text itself.",[13,1665,1666,1667,1670],{},"All three mechanisms operate on the same axis: exit code → tool result framing → LLM reading mode. And they operate ",[71,1668,1669],{},"before"," the actual content. The LLM has already decided how carefully it will read the content before it has read the first content line.",[13,1672,1673,1674,1676,1677],{},"My async default output, no matter how cleanly formulated, sat inside a ",[29,1675,1615],{}," wrapper. The LLM had no reason to read it carefully — the structural framing said ",[20,1678,1679],{},"\"all good, move on.\"",[41,1681,1683],{"id":1682},"the-fix","The Fix",[13,1685,1686],{},"The fix was one line. Exactly one.",[13,1688,1689],{},"Before:",[304,1691,1695],{"className":1692,"code":1693,"language":1694,"meta":312,"style":312},"language-typescript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","printPendingGrantInfo(grant, idp);\nreturn;\n","typescript",[29,1696,1697,1716],{"__ignoreMap":312},[857,1698,1699,1703,1707,1710,1713],{"class":859,"line":860},[857,1700,1702],{"class":1701},"s2Zo4","printPendingGrantInfo",[857,1704,1706],{"class":1705},"sTEyZ","(grant",[857,1708,1709],{"class":863},",",[857,1711,1712],{"class":1705}," idp)",[857,1714,1715],{"class":863},";\n",[857,1717,1718,1722],{"class":859,"line":606},[857,1719,1721],{"class":1720},"s7zQu","return",[857,1723,1715],{"class":863},[13,1725,1726],{},"After:",[304,1728,1730],{"className":1692,"code":1729,"language":1694,"meta":312,"style":312},"printPendingGrantInfo(grant, idp);\nthrow new CliExit(getAsyncExitCode());\n",[29,1731,1732,1744],{"__ignoreMap":312},[857,1733,1734,1736,1738,1740,1742],{"class":859,"line":860},[857,1735,1702],{"class":1701},[857,1737,1706],{"class":1705},[857,1739,1709],{"class":863},[857,1741,1712],{"class":1705},[857,1743,1715],{"class":863},[857,1745,1746,1749,1752,1755,1758,1761,1764],{"class":859,"line":606},[857,1747,1748],{"class":1720},"throw",[857,1750,1751],{"class":863}," new",[857,1753,1754],{"class":1701}," CliExit",[857,1756,1757],{"class":1705},"(",[857,1759,1760],{"class":1701},"getAsyncExitCode",[857,1762,1763],{"class":1705},"())",[857,1765,1715],{"class":863},[13,1767,1768,1769,1772],{},"And ",[29,1770,1771],{},"getAsyncExitCode()"," returns 75 by default.",[13,1774,1775,1776,1778,1779,1782,1783,1786],{},"When the async path is taken, the process now exits with code 75 instead of 0. The output is word-for-word identical (same ",[29,1777,1702],{}," function), but the exit code is different. This flips the tool result framing in openclaw from ",[29,1780,1781],{},"completed"," to ",[29,1784,1785],{},"failed",", and the LLM reads the body with heightened attention.",[41,1788,1790],{"id":1789},"why-75","Why 75",[13,1792,1793,1794,1797,1798,1801,1802,879],{},"75 is not a random value. It's ",[29,1795,1796],{},"EX_TEMPFAIL"," from ",[29,1799,1800],{},"sysexits.h",", a BSD header that has existed since 1983. The convention from ",[29,1803,1804],{},"sysexits(3)",[795,1806,1807],{},[13,1808,1809],{},"EX_TEMPFAIL — temporary failure, indicating something that is not really an error. In sendmail, this means that a mailer (e.g.) could not create a connection, and the request should be reattempted later.",[13,1811,1812,1815,1816,1819,1820,1823,1824,1827,1828],{},[29,1813,1814],{},"sendmail"," has used this for decades as ",[20,1817,1818],{},"\"mail delivery deferred, retry later.\""," ",[29,1821,1822],{},"postfix"," adopted it. ",[29,1825,1826],{},"qmail"," too. It's the standard exit code for ",[20,1829,1830],{},"\"not broken, but not done yet — try again later.\"",[13,1832,1833,1834,1837],{},"This is semantically exactly what a pending grant is. Not an error (the command is syntactically correct, the intent is clear, the grant was created), but a ",[71,1835,1836],{},"temporary deferral"," until a second asynchronous condition (human approval) is met. Then retry.",[13,1839,1840,1841,1843,1844,1847],{},"And because ",[29,1842,1800],{}," has been part of virtually every Unix manual since the BSD era, it's also deeply embedded in LLM training data across decades. When the LLM looks up ",[20,1845,1846],{},"\"exit code 75 meaning,\""," it immediately finds a clear answer: temporary failure, retry later. That's the semantic bridge I needed for \"async grant\" semantics, without having to invent it myself.",[13,1849,1850],{},"Alternative exit codes I considered:",[736,1852,1853,1862,1871,1888,1897],{},[236,1854,1855,1858,1859],{},[71,1856,1857],{},"1"," (POSIX general error) — too generic, ",[20,1860,1861],{},"\"something is broken\"",[236,1863,1864,1867,1868],{},[71,1865,1866],{},"2"," (shell usage error) — reads as ",[20,1869,1870],{},"\"user error\"",[236,1872,1873,1876,1877,1880,1881,1884,1885],{},[71,1874,1875],{},"73"," (",[29,1878,1879],{},"EX_CANTCREAT",") — closer to ",[20,1882,1883],{},"\"resource unavailable\""," than ",[20,1886,1887],{},"\"retry later\"",[236,1889,1890,1876,1893,1896],{},[71,1891,1892],{},"74",[29,1894,1895],{},"EX_IOERR",") — too low-level",[236,1898,1899,1876,1902,1905],{},[71,1900,1901],{},"78",[29,1903,1904],{},"EX_CONFIG",") — implies configuration error",[13,1907,1908,1909,1912],{},"All weaker fits than 75. Plus 75 has the bonus story of sendmail's ",[20,1910,1911],{},"\"defer and retry\""," semantics.",[41,1914,1916],{"id":1915},"before-and-after","Before and After",[13,1918,1919],{},"What openclaw sees now, with the fix:",[13,1921,1922,1925],{},[71,1923,1924],{},"Output before 0.10.0"," (exit 0):",[304,1927,1930],{"className":1928,"code":1929,"language":309},[307],"✔ Grant e887a7e3-... created (pending approval)\n  Approve:   https://id.openape.at/grant-approval?grant_id=e887a7e3-...\n  Execute:   apes grants run e887a7e3-...\n\n  For agents: poll `apes grants status e887a7e3-... --json` every 10s...\n",[29,1931,1929],{"__ignoreMap":312},[13,1933,1934,1935,81,1937,1940,1941,1944],{},"Tool wrapper: ",[29,1936,1615],{},[29,1938,1939],{},"exitCode: 0"," → LLM: ",[20,1942,1943],{},"\"task done, move on\""," → ignores the agent block.",[13,1946,1947,1950],{},[71,1948,1949],{},"Output after 0.10.0"," (exit 75):",[13,1952,1953,1954,81,1956,1959,1960,1940,1963,1966],{},"Identical output. Tool wrapper: ",[29,1955,1628],{},[29,1957,1958],{},"exitCode: 75",", plus automatic suffix ",[20,1961,1962],{},"\"(Command exited with code 75)\"",[20,1964,1965],{},"\"needs attention, read carefully\""," → finds the agent instructions → polls → approved → reports result.",[13,1968,1969],{},"The content body didn't change. The agent instructions were always there. Only the structural anchor made them visible.",[41,1971,1973],{"id":1972},"the-meta-lesson","The Meta-Lesson",[13,1975,1976],{},"The lesson is broader than my CLI. It applies to any tool that wants to talk to an LLM agent:",[13,1978,1979],{},[71,1980,1981],{},"If you want an AI agent to follow specific instructions in your tool output, you need two things: the content itself, and a structural metadata anchor that signals the agent to read the content carefully.",[13,1983,1984],{},"Content alone isn't enough. I had written the best possible agent-instructive text — directly addressed, unambiguous, with exact sub-commands — and it wasn't enough because the structural framing was working against the content.",[13,1986,1987,1988,1991],{},"Metadata anchor alone isn't enough either. An ",[29,1989,1990],{},"exit 75"," without content would be confusing for the LLM. It would need the content to understand what to do.",[13,1993,1994],{},"Both together work. Either one alone doesn't.",[13,1996,1997],{},"The transferable structural anchors I know of, sorted by portability:",[736,1999,2000,2006,2012,2018],{},[236,2001,2002,2005],{},[71,2003,2004],{},"exit code"," — hardest, most direct, most portable. Works in every POSIX-based tool-call wrapper, including Claude Code, Cursor, openclaw, and anything that comes after.",[236,2007,2008,2011],{},[71,2009,2010],{},"stderr routing"," — second. Many wrapper implementations show stderr with different emphasis than stdout, but not as reliable as exit code.",[236,2013,2014,2017],{},[71,2015,2016],{},"tool result status"," (success/failed) — set directly when you build your own tool frameworks. For CLIs, indirectly via exit code.",[236,2019,2020,2023,2024,2027],{},[71,2021,2022],{},"framework-specific priority flags"," — e.g. MCP servers have explicit ",[29,2025,2026],{},"priority"," metadata. Very effective, but only portable within a framework.",[13,2029,2030],{},"In my case, exit code was the right lever because openclaw and most other agent frameworks tie their tool wrapping to exit code. If openclaw had a proprietary priority flag, that would have been the right lever. The rule is: find the lever your target framework actually consumes, and use it in parallel with the narrative content.",[41,2032,2034],{"id":2033},"then-came-the-second-problem","Then Came the Second Problem",[13,2036,2037,2038,2041,2042,2044,2045,2048,2049,2052],{},"After ",[29,2039,2040],{},"0.10.0"," was live, I ran the same test again. openclaw saw the new exit code, the result framing was now ",[29,2043,1785],{},", the LLM read the content carefully, found the ",[20,2046,2047],{},"\"For agents:\""," line, and actually ",[71,2050,2051],{},"started polling",". I saw it in the logs. Two polls 10 seconds apart, exactly as planned.",[13,2054,2055],{},"Then it stopped.",[13,2057,2058],{},"I approved on my phone. I waited. Nothing. I messaged openclaw again. It sent me the approval URL again via Telegram and waited for my reaction.",[13,2060,2061,2062,2065],{},"So for the second time that day I asked the same question: ",[20,2063,2064],{},"\"Why did you stop polling?\""," And for the second time, a disturbingly clear answer came:",[795,2067,2068],{},[13,2069,2070],{},[20,2071,2072],{},"\"I stopped polling because I reacted to your message instead of stubbornly continuing to poll. That was wrong — the instruction says wait 5 minutes, no matter what.\"",[13,2074,2075],{},[968,2076],{"alt":2077,"src":2078},"Agent response in Telegram chat: the agent explains it stopped polling because it reacted to the user message instead of stubbornly continuing to wait for approval","https://sos-at-vie-2.exo.io/dm-public/blog/2026-04-16/quote-polling.png",[13,2080,2081,2082,2084,2085,2088],{},"And this is the moment I realized that ",[29,2083,2040],{}," wasn't the end of the story, but the halfway point. Exit 75 solved the attention problem. But it left another problem unchanged — one that doesn't live in the tool, but in the ",[71,2086,2087],{},"architecture of chat agents"," themselves.",[41,2090,2092],{"id":2091},"why-turn-based-polling-fails-architecturally","Why Turn-Based Polling Fails Architecturally",[13,2094,2095,2096,2099],{},"A chat agent like openclaw is ",[71,2097,2098],{},"turn-based",". It receives a user message, thinks, makes tool calls, responds. Then the turn ends. The agent has no persistent background worker. It has no running timer that ticks independently of user messages. Its entire execution lifecycle is coupled to turn boundaries.",[13,2101,2102,2103,2106,2107,2110,2111],{},"When I tell the agent ",[20,2104,2105],{},"\"poll every 10 seconds for up to 5 minutes,\""," I'm technically asking it to perform 30 poll operations in a single turn, with sleep intervals in between, while the user potentially sends new messages that the agent should ignore. This goes ",[71,2108,2109],{},"against the entire chat UX",": a chat agent that doesn't respond to user input for 5 minutes feels broken. That's why openclaw correctly stopped polling when I messaged it. From its perspective, this wasn't a bug — it was normal chat prioritization: ",[20,2112,2113],{},"\"the user is writing, I need to respond.\"",[13,2115,2116,2117,2119,2120],{},"My ",[20,2118,2047],{}," instruction had tried to impose behavior on the agent that contradicts its fundamental execution model. Even a perfectly attentive agent that had read and understood the instructions 100% couldn't have followed them without behaving unnaturally. This is the second lesson: ",[71,2121,2122],{},"content + structural anchor isn't enough when the content demands an action that works against the agent's architecture.",[41,2124,2126],{"id":2125},"the-second-fix-shifting-the-orchestration","The Second Fix — Shifting the Orchestration",[13,2128,2129,2130,2133],{},"The solution was a realization about division of labor: ",[71,2131,2132],{},"the agent shouldn't poll. The CLI should poll."," The agent should make a single blocking tool call that polls internally and only returns when a terminal state is reached (approved, denied, timeout). Then it delivers a normal tool result with exit 0 (on approved + execute) or non-zero (on denied/timeout).",[13,2135,2136,2137,2140],{},"The elegant part: openclaw already has the perfect lever for this, and I need to change ",[71,2138,2139],{},"zero lines in openclaw",". The exec runtime tool has two mechanisms I had already seen in the code reading from the diagnosis section, but hadn't connected before:",[736,2142,2143,2155],{},[236,2144,2145,2150,2151,2154],{},[71,2146,2147],{},[29,2148,2149],{},"yieldMs",": openclaw's exec can ",[20,2152,2153],{},"\"yield to background\""," after a configurable delay. The turn ends, the process keeps running, the agent can inform the user in the meantime.",[236,2156,2157,2162,2163,2166],{},[71,2158,2159],{},[29,2160,2161],{},"notifyOnExit",": as soon as the background process terminates, this ",[71,2164,2165],{},"automatically triggers a new agent turn"," with the final exit code and the complete output.",[13,2168,2169,2170,2173,2174,2177],{},"Together, these are exactly the primitives for a ",[71,2171,2172],{},"\"long-running command that answers when done\""," flow. I didn't have to invent them. I just had to deliver a CLI command that uses this shape well. The new command in ",[29,2175,2176],{},"0.10.1"," is:",[304,2179,2182],{"className":2180,"code":2181,"language":309},[307],"apes grants run \u003Cgrant-id> --wait\n",[29,2183,2181],{"__ignoreMap":312},[13,2185,2186,2187,2190],{},"The flag is additive and explicitly opt-in. ",[29,2188,2189],{},"--wait"," does the following: if the grant is still pending, the CLI internally polls the status every few seconds until it's either approved (then execute) or terminal (denied/revoked/used → error) or the 5-minute window has expired (→ timeout error). No polling code in the agent. No imperative text. Just a shell command with standard semantics: blocks until done, returns exit 0 on success, non-zero on failure.",[13,2192,2193],{},"The resulting flow:",[304,2195,2198],{"className":2196,"code":2197,"language":309},[307],"Agent Turn 1:\n  openclaw calls `apes grants run \u003Cid> --wait`\n  exec yields to background after 2 seconds\n  openclaw tells the user: \"Please approve here: \u003Curl>\"\n  Turn ends.\n\n(Time passes. User approved in the browser. The CLI polls, sees approved, executes the command, exit 0.)\n\nAgent Turn 2 (automatically via notifyOnExit):\n  openclaw gets the final output\n  tells the user: \"Done: \u003Coutput>\"\n",[29,2199,2197],{"__ignoreMap":312},[13,2201,2202,2203,2206,2207,2209],{},"No polling loop in the agent. No self-discipline around user messages. No unnaturalness. The agent makes ",[71,2204,2205],{},"one"," tool invocation and reacts to ",[71,2208,2205],{}," exit event. This is exactly the mental model chat agents are built for.",[41,2211,2213],{"id":2212},"the-deeper-lesson","The Deeper Lesson",[13,2215,2216],{},"Both fixes together produce a rule I couldn't have formulated before this session:",[13,2218,2219],{},[71,2220,2221],{},"Tools that want to talk to AI agents need to consider two things simultaneously: how the agent reads the content (structural metadata anchor), and what the agent can actually do with its architecture (its native execution primitives).",[13,2223,2224,2225,2227],{},"Act 1 (",[29,2226,2040],{},", exit 75) was the first half: structural attention signaling. It's necessary because without it the agent doesn't read the content carefully at all.",[13,2229,2230,2231,81,2233,2235,2236,2239],{},"Act 2 (",[29,2232,2176],{},[29,2234,2189],{},") was the second half: when the content demands a complex action that doesn't fit in a single turn, the action must be ",[71,2237,2238],{},"moved into the CLI",", not forced onto the agent. The agent stays on what it can natively do — a tool call whose result it reads. Everything else is fighting against the architecture.",[13,2241,2242,2243,2246],{},"The combination of both: ",[71,2244,2245],{},"structural anchor + native primitives",". Content-plus-framing is the theory, yieldMs-plus-notifyOnExit are the concrete levers. Together they produce a communication channel between CLI and agent that is neither imperative nor fragile — it's declarative and uses already existing infrastructure.",[13,2248,2249,2250,2253],{},"And the best part: both fixes required ",[71,2251,2252],{},"zero changes to openclaw",". The entire solution lives on the CLI side. This is the adapter-instead-of-replacement pattern I first articulated in my hero launch post a week ago, now concretely applied: I plugged into openclaw's existing extension points (exit code as tool-result-status, yieldMs as background-yield-primitive, notifyOnExit as turn-re-trigger) instead of modifying openclaw itself.",[41,2255,2257],{"id":2256},"from-090-to-0101","From 0.9.0 to 0.10.1",[13,2259,2260,2261,1782,2264,2266],{},"This all happened in a single working arc — ",[29,2262,2263],{},"0.9.0",[29,2265,2176],{},", with several minor and patch versions in between. Every release came from a live observation, not from pre-planning. I released something, tested it against openclaw, found a divergence between expectation and behavior, built the fix, made the next release.",[13,2268,2269,2270,2273,2274,2277,2278,2281],{},"Two of these releases came directly from the same question to the same agent: ",[20,2271,2272],{},"\"why didn't you do what you were supposed to?\""," Twice a precise, honest answer came — once about attention (",[20,2275,2276],{},"\"I simply ignored it\"","), once about architecture (",[20,2279,2280],{},"\"I reacted to your message\"","). Both answers triggered a release each.",[13,2283,2284,2285,32,2288,2291],{},"This is the most valuable working model I take from the week. Not chasing shorter release cycles, but getting faster into the feedback loop between ",[20,2286,2287],{},"\"I think it works\"",[20,2289,2290],{},"\"here reality shows me it doesn't.\""," And the fastest path to that reality is often not logging, not tracing, not writing unit tests — but simply asking the agent why it did or didn't do what you expected.",[13,2293,2294,2295,2298],{},"This isn't possible with every problem. Deterministic systems ignore such questions. But LLM agents are not deterministic systems. They have a form of self-observation that can be retrieved on request. Not as a debugging replacement, but as a ",[71,2296,2297],{},"fast first hypothesis"," before you reach for deeper tools. Twice today the first hypothesis led me straight to the solution.",[41,2300,2302],{"id":2301},"whats-next","What's Next",[13,2304,2305,2307],{},[29,2306,2176],{}," is live on npm. The two release loops from today's session are closed. What's still open:",[736,2309,2310,2328],{},[236,2311,2312,2313,2316,2317,2319,2320,2323,2324,2327],{},"A dedicated workflow file that the agent can retrieve via ",[29,2314,2315],{},"apes workflow show async-grant",", as a protocol-native counterpart to the ad-hoc ",[20,2318,2047],{}," line. The next stage beyond ",[20,2321,2322],{},"\"content-plus-structural-anchor\""," toward ",[20,2325,2326],{},"\"structured agent protocol with its own retrieval path.\""," Not built yet.",[236,2329,2330],{},"A tripwire test that runs a real agent against the IdP to verify the async grant flow works end to end. Would be the best regression guard I could have. Also not built yet.",[13,2332,2333,2334,2337],{},"But the direction is clear. And the lesson that really occupies me is not the technical one, but the methodological one: ",[71,2335,2336],{},"if you're building a tool that needs to talk to an AI agent, ask the agent directly what it sees and how it interprets it."," Not just write unit tests. Not just document specs. Ask the agent. It's often surprisingly honest about its own blind spots — if you just ask.",[584,2339],{},[13,2341,2342],{},[20,2343,2344],{},"What's your pattern for when a CLI tool needs to tell an AI agent something it must follow? If you have concrete examples, I'd love to hear them — I'm collecting them.",[13,2346,2347],{},[20,2348,2349,2352,2353,2356,2357,2360],{},[29,2350,2351],{},"@openape/apes@0.10.1"," is on npm. The code is at ",[574,2354,596],{"href":593,"rel":2355},[595],". The ",[574,2358,1452],{"href":1450,"rel":2359},[595]," tell how OpenApe came about and how we got here.",[1455,2362,2363],{},"html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":312,"searchDepth":606,"depth":606,"links":2365},[2366,2367,2368,2369,2370,2371,2372,2373,2374,2375,2376,2377,2378],{"id":1522,"depth":606,"text":1523},{"id":1549,"depth":606,"text":1550},{"id":1586,"depth":606,"text":1587},{"id":1682,"depth":606,"text":1683},{"id":1789,"depth":606,"text":1790},{"id":1915,"depth":606,"text":1916},{"id":1972,"depth":606,"text":1973},{"id":2033,"depth":606,"text":2034},{"id":2091,"depth":606,"text":2092},{"id":2125,"depth":606,"text":2126},{"id":2212,"depth":606,"text":2213},{"id":2256,"depth":606,"text":2257},{"id":2301,"depth":606,"text":2302},"2026-04-16","Same question, asked twice. Two different answers. Two releases. An article about structural metadata anchors, sendmail's EX_TEMPFAIL from 1983, turn-based chat architecture limits, and the moment I asked my agent twice why it ignored me — with two disturbingly clear answers.",{},"/blog/en/when-your-agent-doesnt-do-what-you-want-ask-it-why",{"title":1482,"description":2380},"blog/en/when-your-agent-doesnt-do-what-you-want-ask-it-why",[628,2386,2387,631,629],"LLM Tools","CLI Design","when-your-agent-doesnt-do-what-you-want-ask-it-why","1Nn5AWUzOD64lybtwEkQa2T4GxmMen1Tf189azWE6XY",{"id":2391,"title":2392,"author":8,"body":2393,"date":2842,"description":2843,"draft":620,"extension":621,"image":3,"meta":2844,"navigation":623,"path":2845,"seo":2846,"stem":2847,"tags":2848,"translationKey":2852,"__hash__":2853},"blog_en/blog/en/blocking-was-the-bug.md","Blocking Was the Bug",{"type":10,"value":2394,"toc":2835},[2395,2398,2405,2421,2427,2430,2434,2530,2533,2537,2540,2562,2568,2578,2592,2595,2599,2606,2617,2630,2633,2660,2663,2691,2694,2698,2705,2742,2752,2772,2778,2781,2785,2788,2795,2801,2803,2812,2815,2818,2820],[13,2396,2397],{},"On Saturday I thought the shell was done.",[13,2399,2400,2401,2404],{},"I had spent the weekend rebuilding ",[29,2402,2403],{},"ape-shell"," from an argv-rewriting wrapper into a real interactive shell: persistent bash over a pty bridge, marker-based prompt detection, grant integration directly in the REPL, per-session audit logging, install as login shell. Seven milestones, ten pull requests. The test suite was green, I was using it as my login shell in production, and I was convinced the thing was ready.",[13,2406,2407,2408,2410,2411,81,2414,2417,2418],{},"On Sunday I noticed one more thing that creaked architecturally. When an AI agent on the other end of the Telegram connection sends a command that needs a grant, the shell blocks in a polling loop waiting for approval. The user sees nothing about this in Telegram, because nobody tells them that something is waiting. So I built a notification component: when ",[29,2409,2403],{}," enters the wait state, it calls a configured shell command, and that command can do whatever it wants — Telegram bot, macOS notification, ",[29,2412,2413],{},"say",[29,2415,2416],{},"ntfy",", anything. Fire-and-forget, detached, ten-second kill timeout. Six unit tests plus one E2E test, committed Sunday evening. I thought: ",[20,2419,2420],{},"now the user also knows when to approve.",[13,2422,2423,2424,2426],{},"On Monday I used the whole thing end-to-end for the first time: openclaw on my machine at home, ",[29,2425,2403],{}," as the login shell of the openclaw user, commands via Telegram, me sitting at the table with my phone in hand, two meters from the server. The setup was supposed to work in the simplest configuration before I tested anything harder.",[13,2428,2429],{},"What I found was a series of very different problems. One of them was fundamental enough that in the end I didn't fix a bug — I rethought a design decision backwards and recut half the model.",[41,2431,2433],{"id":2432},"what-came-up-during-the-first-test","What Came Up During the First Test",[736,2435,2436,2449,2459,2477,2497,2511,2524],{},[236,2437,2438,2441,2442,2445,2446,2448],{},[71,2439,2440],{},"My agent doesn't even use the shell for simple file operations."," openclaw has built-in tools for reading, writing, editing — those run directly, never going through ",[29,2443,2444],{},"exec",". ",[29,2447,2403],{}," protects a layer that the agent doesn't enter for many tasks anyway. This isn't a bug, it's a category mismatch in my own security model.",[236,2450,2451,2454,2455,2458],{},[71,2452,2453],{},"Cache hits on already-approved grants run silently."," When the agent executes a command for which the current session already has an approval, the behavior from outside is indistinguishable from ",[20,2456,2457],{},"\"there is no shell at all\"",": no ack, no log, nothing.",[236,2460,2461,2464,2465,2468,2469,2472,2473,2476],{},[71,2462,2463],{},"The approval flow itself was invisible."," You'd see ",[20,2466,2467],{},"\"Requesting grant for: ...\"",", then ",[20,2470,2471],{},"\"Approve at: ...\"",", click in the browser, and then the command output just appeared. The one line ",[20,2474,2475],{},"\"Grant approved, continuing\""," that makes the state flip visible was missing.",[236,2478,2479,2485,2486,2489,2490,2492,2493,2496],{},[71,2480,2481,2484],{},[29,2482,2483],{},"apes grants list"," from inside the REPL broke."," If you type ",[29,2487,2488],{},"apes \u003Csubcommand>"," inside an interactive ",[29,2491,2403],{}," session, you get ",[29,2494,2495],{},"ape-shell: unsupported invocation",". Self-inspection impossible. The reason turned out to be very unpleasant — more on that shortly.",[236,2498,2499,2502,2503,2506,2507,2510],{},[71,2500,2501],{},"The Silent Agent Block."," The core symptom: agent says in Telegram ",[20,2504,2505],{},"\"please approve the grant,\""," I approve in the browser, then nothing happens. After a while I have to type ",[20,2508,2509],{},"\"confirmed\""," in the chat so the agent continues. The loop doesn't close on its own.",[236,2512,2513,2516,2517,2519,2520,2523],{},[71,2514,2515],{},"The REPL can enter an unrecoverable state"," without any way from inside to figure out what's broken or repair anything. Made worse by the broken ",[29,2518,2483],{},", because that also killed ",[29,2521,2522],{},"apes whoami"," as a last resort.",[236,2525,2526,2529],{},[71,2527,2528],{},"The diagnosis paradox."," All my tools to inspect the shell live inside the shell. If the shell is broken, the diagnosis is broken too.",[13,2531,2532],{},"Most of these are visible UX and observability holes. Fixable. One — the Silent Agent Block — was something else.",[41,2534,2536],{"id":2535},"my-hypotheses-were-all-wrong-for-the-same-reason","My Hypotheses Were All Wrong, for the Same Reason",[13,2538,2539],{},"I got stuck on the Silent Agent Block because it felt like the hardest one. I wrote down several hypotheses, each with a reproduction test and a derived fix.",[13,2541,2542,2543,2545,2546,2549,2550,2553,2554,2557,2558,2561],{},"One: the LLM doesn't poll. When ",[29,2544,2444],{}," in openclaw returns to the agent after about half a minute with ",[20,2547,2548],{},"\"Command still running, session X, use process tool for follow-up,\""," the LLM should issue a ",[29,2551,2552],{},"process(action=poll, sessionId=X, timeout=...)"," as its next step. In my case it didn't — it passed the ",[20,2555,2556],{},"\"please approve\""," message to Telegram and ended its turn. When the grant later arrived and the background process terminated, openclaw correctly called ",[29,2559,2560],{},"requestHeartbeatNow(\"exec-event\")",", but the agent still didn't wake up because it found nothing new in the user message queue. Fix: stronger hint in the yield result that forces the LLM to schedule the poll.",[13,2563,2564,2565,2567],{},"Another: the heartbeat wake targets the wrong session. Maybe the session key of the backgrounded ",[29,2566,2444],{}," run isn't identical to the session key of the Telegram-bound agent session — then the wake fires into the void. Fix: repair session key mapping.",[13,2569,2570,2571,2573,2574,2577],{},"And a third: ",[29,2572,2403],{}," doesn't terminate cleanly after approval. Maybe the grant wait loop ran through to approval, but the shell child stayed in a state afterward that openclaw can't see as ",[20,2575,2576],{},"\"exit.\""," Fix: correct exit semantics in the grant dispatcher.",[13,2579,2580,2581,2583,2584,2587,2588,2591],{},"I wrote the plan, looked at it, and then realized the hypotheses all do the same thing. They ask ",[71,2582,1651],{}," to get the waiting process to correctly wake up the agent. None of them asks ",[71,2585,2586],{},"why"," there's waiting in the first place. They take ",[20,2589,2590],{},"\"shell blocks until grant is approved\""," as given and try to fix the waking afterward.",[13,2593,2594],{},"This is where I spent a while looking in the wrong corner before the penny dropped. Blocking was the bug. Not the timeout, not the heartbeat, not the session key. The wait itself.",[41,2596,2598],{"id":2597},"why-waiting-was-the-wrong-primitive","Why Waiting Was the Wrong Primitive",[13,2600,2601,2602,2605],{},"My original design felt like normal bash: you fire a command, it runs, it terminates, you get an exit code. The grant flow inserted itself as a ",[20,2603,2604],{},"\"step before execution\""," that the shell synchronously waits through. For a human at the terminal, that's correct. The human sits there, clicks the approval URL, comes back, sees the output. A second, maybe five. No problem.",[13,2607,2608,2609,2612,2613,2616],{},"For an AI agent that communicates with me via Telegram while I'm potentially somewhere else entirely, that's exactly the wrong semantics. The agent just sent a message, ",[20,2610,2611],{},"\"please approve the grant.\""," I'm somewhere — in the same room, in another room, in a conversation, in another task. I might approve immediately, or in two minutes, or this evening. In the meantime ",[71,2614,2615],{},"the agent should not block",". It should be able to handle other requests, respond to other users, work on parallel tasks. The waiting isn't just cosmetically unpleasant — it's architecturally the wrong semantics for an asynchronous human-in-the-loop.",[13,2618,2619,2620,2445,2623,2625,2626,2629],{},"The right default for a grant-secured command is ",[20,2621,2622],{},"fire, announce, exit 0",[29,2624,1529],{}," fires the grant request, prints the ID and approval URL to stdout, fires the configured notification out-of-band, and exits immediately with exit code 0. The agent does other things. I approve in the browser when I get around to it. Later the agent calls ",[29,2627,2628],{},"apes grants run \u003Cid>"," and retrieves the actual command result.",[13,2631,2632],{},"Two steps instead of one, yes — but two steps with a natural handoff point where the agent doesn't have to wait and the human doesn't have to be punctual.",[13,2634,2635,2636,2639,2640,32,2642,2645,2646,2648,2649,2652,2653,2655,2656,2659],{},"This landed last night as ",[29,2637,2638],{},"@openape/apes@0.9.0"," on npm. ",[29,2641,1529],{},[29,2643,2644],{},"ape-shell -c"," are now non-blocking by default. Blocking remains available as opt-in: ",[29,2647,2189],{}," on the command line or ",[29,2650,2651],{},"APE_WAIT=1"," as an environment variable. CI scripts that still want to wait for the actual command's exit code can do exactly that with the flag. The interactive REPL (",[29,2654,2403],{}," without ",[29,2657,2658],{},"-c",") stays unchanged, because there an actual human sits at the prompt who can and should wait.",[13,2661,2662],{},"Together with the pending notification from the previous day's version, this creates a new pattern:",[233,2664,2665,2671,2676,2679,2682,2688],{},[236,2666,2667,2668],{},"Agent fires ",[29,2669,2670],{},"apes run -- curl https://example.com",[236,2672,2673,2675],{},[29,2674,843],{}," creates the grant, prints grant ID, approval URL and execution hint, calls the notification command, exit 0",[236,2677,2678],{},"Agent continues working",[236,2680,2681],{},"I see the notification on my phone, approve in the browser",[236,2683,2684,2685,2687],{},"When the agent is ready, it calls ",[29,2686,2628],{}," and gets the output",[236,2689,2690],{},"No step in this blocks anything",[13,2692,2693],{},"The Silent Agent Block problem isn't fixed by this. It no longer exists. There's no block where something could silently hang, because there's no block at all.",[41,2695,2697],{"id":2696},"what-else-was-fixed-in-order-of-learning-value","What Else Was Fixed, in Order of Learning Value",[13,2699,2700,2701,2704],{},"In parallel with the 0.9.0 redesign, I fixed the other issues in an earlier-landed release — ",[29,2702,2703],{},"@openape/apes@0.8.0",", three PRs.",[13,2706,2707,2713,2714,2717,2718,2720,2721,2724,2725,2728,2729,2731,2732,2734,2735,2737,2738,2741],{},[71,2708,2709,2710,2712],{},"The broken ",[29,2711,2483],{}," in the REPL."," The root cause wasn't what I expected. I had assumed ",[20,2715,2716],{},"\"argv parsing bug or dispatch rule wrong\""," and was prepared to debug in the REPL command handler. The actual reason was a leaking environment marker. ",[29,2719,2403],{}," internally sets ",[29,2722,2723],{},"APES_SHELL_WRAPPER=1"," so the CLI knows ",[20,2726,2727],{},"\"I was invoked as ape-shell.\""," This env var was then inherited through the pty bridge to the bash child, and from there to every ",[29,2730,843],{}," subcommand called within it. The nested ",[29,2733,843],{}," saw the marker and thought ",[20,2736,77],{}," was an ape-shell invocation, couldn't find the subcommand args in the ape-shell argv schema, and threw ",[29,2739,2740],{},"unsupported invocation",". The fix is one line: destructure the marker out of the environment before passing it to bash at pty spawn. The hard part wasn't the fix — it was that I was looking at the wrong level: dispatch logic instead of environment inheritance.",[13,2743,2744,2747,2748,2751],{},[71,2745,2746],{},"The invisible cache hits and approvals"," are two deliberate ",[29,2749,2750],{},"consola.info"," lines in the grant dispatcher. Trivial fixes — I had simply forgotten them during initial construction because I was thinking about function, not observability. A working system is not the same as an observable system, and you almost always notice this only at the moment you want to observe.",[13,2753,2754,2757,2758,81,2761,81,2764,2767,2768,2771],{},[71,2755,2756],{},"REPL recovery and external health probe"," are a few new meta-commands (",[29,2759,2760],{},":help",[29,2762,2763],{},":status",[29,2765,2766],{},":reset",") in the REPL, plus a new subcommand ",[29,2769,2770],{},"apes health"," that runs standalone from any shell and outputs the complete auth and config state. With the latter I circumvented the diagnosis paradox. I can now check from outside whether my shell is healthy without having to enter the potentially broken REPL.",[13,2773,2774,2777],{},[71,2775,2776],{},"The shell bypass I deliberately didn't fix."," More on that in a moment.",[13,2779,2780],{},"All of this is 0.8.0, with some shipping hurdles along the way, but live in the end.",[41,2782,2784],{"id":2783},"what-i-take-away","What I Take Away",[13,2786,2787],{},"What I can't stop thinking about is the moment when I had written down several hypotheses and none of them were right — because they all shared the same assumption.",[13,2789,2790,2791,2794],{},"This is the kind of mistake you make when you take an existing design as given and only search for bugs ",[20,2792,2793],{},"within"," it. The hypotheses were locally well-reasoned — each one, had it been correct, would have led to a clean fix. But locally correct isn't enough when the wrong assumption sits one level above.",[13,2796,2797,2800],{},[71,2798,2799],{},"If during debugging you need multiple parallel hypotheses that all presuppose the same default behavior, stop and ask whether that default behavior is even correct."," Multiple simultaneous hypotheses are a stronger signal for an architecture problem than for a bug. A bug usually has exactly one plausible cause. An architecture problem has several — and each one locally looks like a bug.",[584,2802],{},[13,2804,2805,2808,2809,349],{},[71,2806,2807],{},"Open ending."," The agent bypasses my grant-secured shell entirely for many simple operations, because it has built-in tools that work directly on the filesystem. This isn't a bug. It's a structural property of modern tool-based agent frameworks, and it doesn't disappear by fixing another loop. It raises the question ",[71,2810,2811],{},"what a grant-secured shell is even worth when the agent no longer needs a shell for most actions",[13,2813,2814],{},"I don't have a good answer to this question yet. It's not solvable by a release. It's the topic of the coming weeks, and I'm honestly not sure whether it leads to a feature decision, an architecture decision, or an entirely different product. I still prefer it to a problem where I already know what I'm going to do — because it teaches me something while I think about it.",[13,2816,2817],{},"If any of you have encountered a similar pattern while debugging — where you realized your hypotheses all rested on the same wrong assumption — feel free to write to me. I'm currently collecting examples, not for another post, but because I want to understand the pattern better.",[584,2819],{},[13,2821,2822],{},[20,2823,2824,2826,2827,2356,2830,2834],{},[29,2825,2638],{}," has been live on npm since last night. The code is at ",[574,2828,596],{"href":593,"rel":2829},[595],[574,2831,2833],{"href":1450,"rel":2832},[595],"previous two blog articles"," tell how the shell came about and how the pty bridge that gives it life works.",{"title":312,"searchDepth":606,"depth":606,"links":2836},[2837,2838,2839,2840,2841],{"id":2432,"depth":606,"text":2433},{"id":2535,"depth":606,"text":2536},{"id":2597,"depth":606,"text":2598},{"id":2696,"depth":606,"text":2697},{"id":2783,"depth":606,"text":2784},"2026-04-14","Last weekend I thought my grant-secured shell was done. On Sunday I built a notification for the one remaining weak point. On Monday, during the first real end-to-end test, I found a whole set of problems — and realized that one of them wasn't a bug but a design decision I had made backwards. An article about hypotheses that all shared the same wrong assumption, and the moment you realize you're debugging at the wrong level.",{},"/blog/en/blocking-was-the-bug",{"title":2392,"description":2843},"blog/en/blocking-was-the-bug",[628,2849,2850,631,2851],"Systems Design","Human in the Loop","Debugging","blocking-was-the-bug","OJyT-RC0GHk9bEkvJ8gIDEQ5ToHlB5t_HmV-COjISmI",{"id":2855,"title":2856,"author":8,"body":2857,"date":4334,"description":4335,"draft":620,"extension":621,"image":3,"meta":4336,"navigation":623,"path":4337,"seo":4338,"stem":4339,"tags":4340,"translationKey":4345,"__hash__":4346},"blog_en/blog/en/how-do-i-know-when-bash-is-done.md","How Do I Know When Bash Is Done?",{"type":10,"value":2858,"toc":4314},[2859,2868,2875,2880,2890,2894,2897,2903,2906,2940,2950,2956,2960,2965,2971,2982,2986,2993,2997,3000,3007,3013,3020,3024,3038,3042,3045,3055,3059,3062,3068,3071,3082,3086,3090,3105,3112,3187,3190,3196,3211,3215,3218,3231,3245,3510,3513,3547,3551,3554,3618,3632,3636,3639,4037,4052,4056,4070,4073,4090,4096,4265,4268,4272,4290,4301,4305,4308,4311],[13,2860,2861,2862,2864,2865,2867],{},"Yesterday I wrote here about ",[29,2863,2403],{}," — a shell wrapper that routes commands through a grant system before they're executed. The original version was a pure one-shot mode: you pass in a command, ",[29,2866,2403],{}," gets a grant, bash executes, done. Every command got a fresh bash instance.",[13,2869,2870,2871,2874],{},"That works for ",[29,2872,2873],{},"$SHELL -c"," patterns. It doesn't work for interactive sessions. And the reason is a question that looks trivial at first glance and then turns out to be surprisingly deep:",[13,2876,2877],{},[71,2878,2879],{},"How do I know when bash is done?",[13,2881,2882,2883,2885,2886,2889],{},"This article is the answer. No ",[29,2884,2403],{}," pitch, no grant system discussion — just a concrete systems programming problem and how to solve it. If you've ever tried to build a shell wrapper that controls a ",[20,2887,2888],{},"persistent"," bash across multiple commands, you've probably asked the same question.",[41,2891,2893],{"id":2892},"the-problem","The Problem",[13,2895,2896],{},"The naive version of a shell wrapper is simple:",[304,2898,2901],{"className":2899,"code":2900,"language":309,"meta":312},[307],"spawn bash\nwrite command\nread output\nkill bash\n",[29,2902,2900],{"__ignoreMap":312},[13,2904,2905],{},"This works but is useless for many use cases. Because if every command gets a new bash instance, shell state is lost between commands:",[736,2907,2908,2914,2924,2930,2933],{},[236,2909,2910,2913],{},[29,2911,2912],{},"cd /foo"," in the first command → in the second command you're back in the old directory",[236,2915,2916,2919,2920,2923],{},[29,2917,2918],{},"export FOO=bar"," → in the next command ",[29,2921,2922],{},"$FOO"," is empty",[236,2925,2926,2929],{},[29,2927,2928],{},"alias ll='ls -la'"," → gone",[236,2931,2932],{},"Shell functions → gone",[236,2934,2935,2936,2939],{},"Loaded ",[29,2937,2938],{},".bashrc"," configuration → read, then buried along with the bash instance",[13,2941,2942,2943,2946,2947,2949],{},"For a wrapper that's supposed to feel like a ",[20,2944,2945],{},"real"," shell, this is a dead end. You need ",[71,2948,2205],{}," bash that stays alive, and you push commands into it.",[13,2951,2952,2953],{},"Then the question becomes: ",[20,2954,2955],{},"when is bash done with the current command and ready for the next one?",[41,2957,2959],{"id":2958},"naive-approaches-and-why-they-break","Naive Approaches and Why They Break",[2961,2962,2964],"h3",{"id":2963},"approach-1-just-wait-a-bit","Approach 1: Just Wait a Bit",[304,2966,2969],{"className":2967,"code":2968,"language":309,"meta":312},[307],"write command\nsleep 500ms\nread whatever accumulated\n",[29,2970,2968],{"__ignoreMap":312},[13,2972,2973,2974,2977,2978,2981],{},"Breaks immediately. What happens with ",[29,2975,2976],{},"find / -name '*.log'","? That runs for minutes. What happens with ",[29,2979,2980],{},"yes | head -n 1000000","? That spews megabytes in milliseconds and will certainly extend beyond 500ms. Timeouts are not an answer to a question about semantics.",[2961,2983,2985],{"id":2984},"approach-2-wait-for-newlines","Approach 2: Wait for Newlines",[13,2987,2988,2989,2992],{},"\"If no new newline came for 200ms, bash is done.\" Also wrong. ",[29,2990,2991],{},"tail -f log.txt"," sends lines occasionally, then pauses, then lines again. Newline-based heuristics produce flaky results that look different on every tenth invocation.",[2961,2994,2996],{"id":2995},"approach-3-wait-for-the-prompt","Approach 3: Wait for the Prompt",[13,2998,2999],{},"Bash shows a prompt after every command. If you see the prompt, bash is done. Logical, right?",[13,3001,3002,3003,3006],{},"Only: ",[71,3004,3005],{},"which prompt?"," The user's PS1 is freely configurable. Mine looks roughly like this:",[304,3008,3011],{"className":3009,"code":3010,"language":309,"meta":312},[307],"patrick@mbp ~/code/openape (main *) $ \n",[29,3012,3010],{"__ignoreMap":312},[13,3014,3015,3016,3019],{},"With ANSI colors, with git branch info, with a dirty-state marker, with Unicode decoration. Sometimes multiline. Sometimes with a newline before it. A parser that wants to recognize ",[20,3017,3018],{},"arbitrary"," user PS1 is doomed to fail.",[2961,3021,3023],{"id":3022},"approach-4-parse-ps1","Approach 4: Parse PS1",[13,3025,3026,3027,3030,3031,3033,3034,3037],{},"\"Then let's parse PS1 from ",[29,3028,3029],{},"~/.bashrc","!\" Invalid. PS1 is assembled from environment variables, functions, git hooks, virtualenv wrappers, async status providers, and ten other sources. Statically parsing ",[29,3032,2938],{}," only sees a fraction of that. And even if you had the complete definition — what bash draws at the terminal is the ",[20,3035,3036],{},"result of expansion",", not the source form.",[2961,3039,3041],{"id":3040},"the-core-insight","The Core Insight",[13,3043,3044],{},"Bash doesn't tell you \"I'm done.\" The prompt is the only signal, and it's not reliably readable by default.",[13,3046,3047,3048,3051,3052,349],{},"So the right answer is not ",[20,3049,3050],{},"to read PS1"," — it's ",[71,3053,3054],{},"to override PS1",[41,3056,3058],{"id":3057},"the-trick-your-own-marker","The Trick: Your Own Marker",[13,3060,3061],{},"If bash won't give you a reliable \"done\" indicator, give it one. Override PS1 with a sentinel sequence you've defined yourself, and scan the PTY output for it. When you see the marker, you know bash has finished the last command and is waiting for the next one.",[13,3063,3064,3065],{},"This sounds trivial, but it's the moment where the architecture clicks: ",[71,3066,3067],{},"bash doesn't need to understand that it's running inside a wrapper. You only change how it communicates \"done.\"",[13,3069,3070],{},"The idea in three steps:",[233,3072,3073,3076,3079],{},[236,3074,3075],{},"Generate a marker that can't accidentally appear in user output.",[236,3077,3078],{},"Inject it as PS1 at the start of the bash session.",[236,3080,3081],{},"Scan the PTY stream for the marker. When you see it, everything before it was command output, and bash is ready for the next line.",[41,3083,3085],{"id":3084},"the-details-that-actually-matter","The Details That Actually Matter",[2961,3087,3089],{"id":3088},"random-marker","Random Marker",[13,3091,3092,3093,3096,3097,3100,3101,3104],{},"If you use ",[29,3094,3095],{},"\"PROMPT>\""," as a marker and the user types ",[29,3098,3099],{},"echo \"PROMPT>\"",", you're confused. If you use ",[29,3102,3103],{},"\"___END___\"",", there's probably a log file somewhere in the world that contains that string.",[13,3106,3107,3108,3111],{},"Solution: ",[71,3109,3110],{},"16 bytes of crypto-random as hex."," 32 hex characters, 2^128 possible values. Collision-resistant in any realistic world. In ape-shell it looks like this:",[304,3113,3115],{"className":1692,"code":3114,"language":1694,"meta":312,"style":312},"import { randomBytes } from 'node:crypto'\n\nthis.marker = randomBytes(16).toString('hex')\n",[29,3116,3117,3143,3148],{"__ignoreMap":312},[857,3118,3119,3122,3125,3128,3131,3134,3137,3140],{"class":859,"line":860},[857,3120,3121],{"class":1720},"import",[857,3123,3124],{"class":863}," {",[857,3126,3127],{"class":1705}," randomBytes",[857,3129,3130],{"class":863}," }",[857,3132,3133],{"class":1720}," from",[857,3135,3136],{"class":863}," '",[857,3138,3139],{"class":885},"node:crypto",[857,3141,3142],{"class":863},"'\n",[857,3144,3145],{"class":859,"line":606},[857,3146,3147],{"emptyLinePlaceholder":623},"\n",[857,3149,3150,3153,3156,3159,3161,3163,3167,3169,3171,3174,3176,3179,3182,3184],{"class":859,"line":893},[857,3151,3152],{"class":863},"this.",[857,3154,3155],{"class":1705},"marker ",[857,3157,3158],{"class":863},"=",[857,3160,3127],{"class":1701},[857,3162,1757],{"class":1705},[857,3164,3166],{"class":3165},"sbssI","16",[857,3168,1026],{"class":1705},[857,3170,349],{"class":863},[857,3172,3173],{"class":1701},"toString",[857,3175,1757],{"class":1705},[857,3177,3178],{"class":863},"'",[857,3180,3181],{"class":885},"hex",[857,3183,3178],{"class":863},[857,3185,3186],{"class":1705},")\n",[13,3188,3189],{},"With some structure around it so the regex gets a clear anchor:",[304,3191,3194],{"className":3192,"code":3193,"language":309,"meta":312},[307],"__APES_\u003C32-hex-chars>__:\u003Cexit-code>:__END__\n",[29,3195,3193],{"__ignoreMap":312},[13,3197,3198,3199,3202,3203,3206,3207,3210],{},"The ",[29,3200,3201],{},"__APES_"," prefix makes it human-readable when debugging. The ",[29,3204,3205],{},":__END__"," suffix gives the regex an unambiguous terminator. And the ",[29,3208,3209],{},"\u003Cexit-code>"," in the middle is the trick for bonus feature number one: you get the exit code of the last command together with the done signal, in a single pattern-match operation.",[2961,3212,3214],{"id":3213},"prompt_command-not-just-ps1","PROMPT_COMMAND, Not Just PS1",[13,3216,3217],{},"This is where almost every first implementation introduces a bug. It's not enough to set PS1 once at startup. Because:",[13,3219,3220,3226,3227,3230],{},[71,3221,3222,3223,3225],{},"The user's ",[29,3224,2938],{}," is read after your start."," If it contains ",[29,3228,3229],{},"PS1='...'"," — and that's the rule, not the exception — your carefully set marker PS1 gets overwritten. The user isn't at fault, but your wrapper breaks.",[13,3232,3233,3234,3237,3238,3241,3242,3244],{},"The solution is a bash variable most people don't know: ",[29,3235,3236],{},"PROMPT_COMMAND",". This is a shell command that bash executes ",[71,3239,3240],{},"before every prompt rendering",". If you set PS1 there, you override all of the user's ",[29,3243,2938],{}," configurations before the next prompt is drawn:",[304,3246,3248],{"className":1692,"code":3247,"language":1694,"meta":312,"style":312},"this.term = pty.spawn('bash', ['--login', '-i'], {\n  name: 'xterm-256color',\n  cols,\n  rows,\n  cwd: options.cwd ?? process.cwd(),\n  env: {\n    ...process.env,\n    // Force our marker PS1 on every prompt — survives .bashrc overrides.\n    PROMPT_COMMAND: `PS1='__APES_${this.marker}__:$?:__END__'`,\n    // Also set it initially so the very first prompt carries the marker.\n    PS1: `__APES_${this.marker}__:$?:__END__`,\n    PS2: '> ',\n    BASH_SILENCE_DEPRECATION_WARNING: '1',\n  },\n})\n",[29,3249,3250,3304,3321,3328,3335,3366,3375,3391,3398,3431,3437,3464,3481,3497,3503],{"__ignoreMap":312},[857,3251,3252,3254,3257,3259,3262,3264,3267,3269,3271,3274,3276,3278,3280,3282,3285,3287,3289,3291,3294,3296,3299,3301],{"class":859,"line":860},[857,3253,3152],{"class":863},[857,3255,3256],{"class":1705},"term ",[857,3258,3158],{"class":863},[857,3260,3261],{"class":1705}," pty",[857,3263,349],{"class":863},[857,3265,3266],{"class":1701},"spawn",[857,3268,1757],{"class":1705},[857,3270,3178],{"class":863},[857,3272,3273],{"class":885},"bash",[857,3275,3178],{"class":863},[857,3277,1709],{"class":863},[857,3279,926],{"class":1705},[857,3281,3178],{"class":863},[857,3283,3284],{"class":885},"--login",[857,3286,3178],{"class":863},[857,3288,1709],{"class":863},[857,3290,3136],{"class":863},[857,3292,3293],{"class":885},"-i",[857,3295,3178],{"class":863},[857,3297,3298],{"class":1705},"]",[857,3300,1709],{"class":863},[857,3302,3303],{"class":863}," {\n",[857,3305,3306,3310,3312,3314,3317,3319],{"class":859,"line":606},[857,3307,3309],{"class":3308},"swJcz","  name",[857,3311,879],{"class":863},[857,3313,3136],{"class":863},[857,3315,3316],{"class":885},"xterm-256color",[857,3318,3178],{"class":863},[857,3320,890],{"class":863},[857,3322,3323,3326],{"class":859,"line":893},[857,3324,3325],{"class":1705},"  cols",[857,3327,890],{"class":863},[857,3329,3330,3333],{"class":859,"line":914},[857,3331,3332],{"class":1705},"  rows",[857,3334,890],{"class":863},[857,3336,3337,3340,3342,3345,3347,3350,3353,3356,3358,3361,3364],{"class":859,"line":938},[857,3338,3339],{"class":3308},"  cwd",[857,3341,879],{"class":863},[857,3343,3344],{"class":1705}," options",[857,3346,349],{"class":863},[857,3348,3349],{"class":1705},"cwd ",[857,3351,3352],{"class":863},"??",[857,3354,3355],{"class":1705}," process",[857,3357,349],{"class":863},[857,3359,3360],{"class":1701},"cwd",[857,3362,3363],{"class":1705},"()",[857,3365,890],{"class":863},[857,3367,3368,3371,3373],{"class":859,"line":958},[857,3369,3370],{"class":3308},"  env",[857,3372,879],{"class":863},[857,3374,3303],{"class":863},[857,3376,3378,3381,3384,3386,3389],{"class":859,"line":3377},7,[857,3379,3380],{"class":863},"    ...",[857,3382,3383],{"class":1705},"process",[857,3385,349],{"class":863},[857,3387,3388],{"class":1705},"env",[857,3390,890],{"class":863},[857,3392,3394],{"class":859,"line":3393},8,[857,3395,3397],{"class":3396},"sHwdD","    // Force our marker PS1 on every prompt — survives .bashrc overrides.\n",[857,3399,3401,3404,3406,3409,3412,3415,3417,3420,3423,3426,3429],{"class":859,"line":3400},9,[857,3402,3403],{"class":3308},"    PROMPT_COMMAND",[857,3405,879],{"class":863},[857,3407,3408],{"class":863}," `",[857,3410,3411],{"class":885},"PS1='__APES_",[857,3413,3414],{"class":863},"${",[857,3416,3152],{"class":863},[857,3418,3419],{"class":1705},"marker",[857,3421,3422],{"class":863},"}",[857,3424,3425],{"class":885},"__:$?:__END__'",[857,3427,3428],{"class":863},"`",[857,3430,890],{"class":863},[857,3432,3434],{"class":859,"line":3433},10,[857,3435,3436],{"class":3396},"    // Also set it initially so the very first prompt carries the marker.\n",[857,3438,3440,3443,3445,3447,3449,3451,3453,3455,3457,3460,3462],{"class":859,"line":3439},11,[857,3441,3442],{"class":3308},"    PS1",[857,3444,879],{"class":863},[857,3446,3408],{"class":863},[857,3448,3201],{"class":885},[857,3450,3414],{"class":863},[857,3452,3152],{"class":863},[857,3454,3419],{"class":1705},[857,3456,3422],{"class":863},[857,3458,3459],{"class":885},"__:$?:__END__",[857,3461,3428],{"class":863},[857,3463,890],{"class":863},[857,3465,3467,3470,3472,3474,3477,3479],{"class":859,"line":3466},12,[857,3468,3469],{"class":3308},"    PS2",[857,3471,879],{"class":863},[857,3473,3136],{"class":863},[857,3475,3476],{"class":885},"> ",[857,3478,3178],{"class":863},[857,3480,890],{"class":863},[857,3482,3484,3487,3489,3491,3493,3495],{"class":859,"line":3483},13,[857,3485,3486],{"class":3308},"    BASH_SILENCE_DEPRECATION_WARNING",[857,3488,879],{"class":863},[857,3490,3136],{"class":863},[857,3492,1857],{"class":885},[857,3494,3178],{"class":863},[857,3496,890],{"class":863},[857,3498,3500],{"class":859,"line":3499},14,[857,3501,3502],{"class":863},"  },\n",[857,3504,3506,3508],{"class":859,"line":3505},15,[857,3507,3422],{"class":863},[857,3509,3186],{"class":1705},[13,3511,3512],{},"Three details that aren't obvious:",[736,3514,3515,3523,3539],{},[236,3516,3517,3522],{},[71,3518,3519],{},[29,3520,3521],{},"--login -i",": you want the user's rcfiles to be read, otherwise aliases, functions, and environment the user expects are missing. The trade-off is exactly why you need the PROMPT_COMMAND trick.",[236,3524,3525,3530,3531,3534,3535,3538],{},[71,3526,3527],{},[29,3528,3529],{},"PS2='> '",": this is the ",[20,3532,3533],{},"secondary prompt"," bash uses when a command spans multiple lines (unclosed quote, continued pipe, ",[29,3536,3537],{},"if"," block). You set it to something simple so you can recognize it during multi-line handling.",[236,3540,3541,3546],{},[71,3542,3543],{},[29,3544,3545],{},"BASH_SILENCE_DEPRECATION_WARNING=1",": on macOS, the system bash prints a deprecation warning to stderr on every start. It pollutes your output stream. Away with it.",[2961,3548,3550],{"id":3549},"the-regex","The Regex",[13,3552,3553],{},"With the marker in the output stream, you can build a regex that matches it and extracts the exit code:",[304,3555,3557],{"className":1692,"code":3556,"language":1694,"meta":312,"style":312},"this.markerRegex = new RegExp(\n  `__APES_${this.marker}__:(-?\\\\d+):__END__\\\\r?\\\\n?`,\n)\n",[29,3558,3559,3576,3614],{"__ignoreMap":312},[857,3560,3561,3563,3566,3568,3570,3573],{"class":859,"line":860},[857,3562,3152],{"class":863},[857,3564,3565],{"class":1705},"markerRegex ",[857,3567,3158],{"class":863},[857,3569,1751],{"class":863},[857,3571,3572],{"class":1701}," RegExp",[857,3574,3575],{"class":1705},"(\n",[857,3577,3578,3581,3583,3585,3587,3589,3591,3594,3597,3600,3602,3605,3607,3610,3612],{"class":859,"line":606},[857,3579,3580],{"class":863},"  `",[857,3582,3201],{"class":885},[857,3584,3414],{"class":863},[857,3586,3152],{"class":863},[857,3588,3419],{"class":1705},[857,3590,3422],{"class":863},[857,3592,3593],{"class":885},"__:(-?",[857,3595,3596],{"class":1705},"\\\\",[857,3598,3599],{"class":885},"d+):__END__",[857,3601,3596],{"class":1705},[857,3603,3604],{"class":885},"r?",[857,3606,3596],{"class":1705},[857,3608,3609],{"class":885},"n?",[857,3611,3428],{"class":863},[857,3613,890],{"class":863},[857,3615,3616],{"class":859,"line":893},[857,3617,3186],{"class":1705},[13,3619,3198,3620,3623,3624,3627,3628,3631],{},[29,3621,3622],{},"\\\\r?\\\\n?"," at the end is a subtle but important point: depending on how bash renders the prompt (on a fresh line or directly after the last output), a newline may or may not follow. The regex tolerates both cases. The group ",[29,3625,3626],{},"(-?\\\\d+)"," captures the exit code, including negative values for signals like ",[29,3629,3630],{},"130"," or unusual conventions.",[2961,3633,3635],{"id":3634},"the-output-parser","The Output Parser",[13,3637,3638],{},"Every PTY chunk that comes in gets appended to a pending buffer and scanned for the marker. When the marker is found, everything before it is the output of the just-finished command:",[304,3640,3642],{"className":1692,"code":3641,"language":1694,"meta":312,"style":312},"private handleData(chunk: string): void {\n  this.pending += chunk\n\n  for (;;) {\n    const match = this.pending.match(this.markerRegex)\n    if (!match || match.index === undefined) break\n\n    const before = this.pending.slice(0, match.index)\n    const exitCode = Number(match[1])\n\n    // Everything before the marker is command output.\n    if (before.length > 0) {\n      this.currentLineBuffer += before\n      this.events.onOutput(before)\n    }\n\n    // Remove marker and everything before it from the buffer.\n    this.pending = this.pending.slice(match.index + match[0].length)\n\n    // Command is done — frame to the consumer.\n    const frame = { output: this.currentLineBuffer, exitCode }\n    this.currentLineBuffer = ''\n    this.events.onLineDone(frame)\n  }\n\n  // What remains in `pending` is either partial output\n  // or a started marker that continues in the next chunk.\n}\n",[29,3643,3644,3660,3674,3678,3693,3723,3756,3760,3793,3817,3821,3826,3849,3862,3880,3885,3890,3896,3939,3944,3950,3978,3990,4009,4015,4020,4026,4032],{"__ignoreMap":312},[857,3645,3646,3649,3652,3655,3658],{"class":859,"line":860},[857,3647,3648],{"class":1705},"private ",[857,3650,3651],{"class":1701},"handleData",[857,3653,3654],{"class":1705},"(chunk: string): ",[857,3656,3657],{"class":863},"void",[857,3659,3303],{"class":863},[857,3661,3662,3665,3668,3671],{"class":859,"line":606},[857,3663,3664],{"class":863},"  this.",[857,3666,3667],{"class":1705},"pending",[857,3669,3670],{"class":863}," +=",[857,3672,3673],{"class":1705}," chunk\n",[857,3675,3676],{"class":859,"line":893},[857,3677,3147],{"emptyLinePlaceholder":623},[857,3679,3680,3683,3685,3688,3691],{"class":859,"line":914},[857,3681,3682],{"class":1720},"  for",[857,3684,1876],{"class":3308},[857,3686,3687],{"class":863},";;",[857,3689,3690],{"class":3308},") ",[857,3692,864],{"class":863},[857,3694,3695,3698,3701,3704,3707,3709,3711,3714,3716,3718,3721],{"class":859,"line":938},[857,3696,3697],{"class":872},"    const",[857,3699,3700],{"class":1705}," match",[857,3702,3703],{"class":863}," =",[857,3705,3706],{"class":863}," this.",[857,3708,3667],{"class":1705},[857,3710,349],{"class":863},[857,3712,3713],{"class":1701},"match",[857,3715,1757],{"class":3308},[857,3717,3152],{"class":863},[857,3719,3720],{"class":1705},"markerRegex",[857,3722,3186],{"class":3308},[857,3724,3725,3728,3730,3733,3735,3738,3740,3742,3745,3748,3751,3753],{"class":859,"line":958},[857,3726,3727],{"class":1720},"    if",[857,3729,1876],{"class":3308},[857,3731,3732],{"class":863},"!",[857,3734,3713],{"class":1705},[857,3736,3737],{"class":863}," ||",[857,3739,3700],{"class":1705},[857,3741,349],{"class":863},[857,3743,3744],{"class":1705},"index",[857,3746,3747],{"class":863}," ===",[857,3749,3750],{"class":863}," undefined",[857,3752,3690],{"class":3308},[857,3754,3755],{"class":1720},"break\n",[857,3757,3758],{"class":859,"line":3377},[857,3759,3147],{"emptyLinePlaceholder":623},[857,3761,3762,3764,3767,3769,3771,3773,3775,3778,3780,3783,3785,3787,3789,3791],{"class":859,"line":3393},[857,3763,3697],{"class":872},[857,3765,3766],{"class":1705}," before",[857,3768,3703],{"class":863},[857,3770,3706],{"class":863},[857,3772,3667],{"class":1705},[857,3774,349],{"class":863},[857,3776,3777],{"class":1701},"slice",[857,3779,1757],{"class":3308},[857,3781,3782],{"class":3165},"0",[857,3784,1709],{"class":863},[857,3786,3700],{"class":1705},[857,3788,349],{"class":863},[857,3790,3744],{"class":1705},[857,3792,3186],{"class":3308},[857,3794,3795,3797,3800,3802,3805,3807,3809,3812,3814],{"class":859,"line":3400},[857,3796,3697],{"class":872},[857,3798,3799],{"class":1705}," exitCode",[857,3801,3703],{"class":863},[857,3803,3804],{"class":1701}," Number",[857,3806,1757],{"class":3308},[857,3808,3713],{"class":1705},[857,3810,3811],{"class":3308},"[",[857,3813,1857],{"class":3165},[857,3815,3816],{"class":3308},"])\n",[857,3818,3819],{"class":859,"line":3433},[857,3820,3147],{"emptyLinePlaceholder":623},[857,3822,3823],{"class":859,"line":3439},[857,3824,3825],{"class":3396},"    // Everything before the marker is command output.\n",[857,3827,3828,3830,3832,3834,3836,3839,3842,3845,3847],{"class":859,"line":3466},[857,3829,3727],{"class":1720},[857,3831,1876],{"class":3308},[857,3833,1669],{"class":1705},[857,3835,349],{"class":863},[857,3837,3838],{"class":1705},"length",[857,3840,3841],{"class":863}," >",[857,3843,3844],{"class":3165}," 0",[857,3846,3690],{"class":3308},[857,3848,864],{"class":863},[857,3850,3851,3854,3857,3859],{"class":859,"line":3483},[857,3852,3853],{"class":863},"      this.",[857,3855,3856],{"class":1705},"currentLineBuffer",[857,3858,3670],{"class":863},[857,3860,3861],{"class":1705}," before\n",[857,3863,3864,3866,3869,3871,3874,3876,3878],{"class":859,"line":3499},[857,3865,3853],{"class":863},[857,3867,3868],{"class":1705},"events",[857,3870,349],{"class":863},[857,3872,3873],{"class":1701},"onOutput",[857,3875,1757],{"class":3308},[857,3877,1669],{"class":1705},[857,3879,3186],{"class":3308},[857,3881,3882],{"class":859,"line":3505},[857,3883,3884],{"class":863},"    }\n",[857,3886,3888],{"class":859,"line":3887},16,[857,3889,3147],{"emptyLinePlaceholder":623},[857,3891,3893],{"class":859,"line":3892},17,[857,3894,3895],{"class":3396},"    // Remove marker and everything before it from the buffer.\n",[857,3897,3899,3902,3904,3906,3908,3910,3912,3914,3916,3918,3920,3922,3925,3927,3929,3931,3933,3935,3937],{"class":859,"line":3898},18,[857,3900,3901],{"class":863},"    this.",[857,3903,3667],{"class":1705},[857,3905,3703],{"class":863},[857,3907,3706],{"class":863},[857,3909,3667],{"class":1705},[857,3911,349],{"class":863},[857,3913,3777],{"class":1701},[857,3915,1757],{"class":3308},[857,3917,3713],{"class":1705},[857,3919,349],{"class":863},[857,3921,3744],{"class":1705},[857,3923,3924],{"class":863}," +",[857,3926,3700],{"class":1705},[857,3928,3811],{"class":3308},[857,3930,3782],{"class":3165},[857,3932,3298],{"class":3308},[857,3934,349],{"class":863},[857,3936,3838],{"class":1705},[857,3938,3186],{"class":3308},[857,3940,3942],{"class":859,"line":3941},19,[857,3943,3147],{"emptyLinePlaceholder":623},[857,3945,3947],{"class":859,"line":3946},20,[857,3948,3949],{"class":3396},"    // Command is done — frame to the consumer.\n",[857,3951,3953,3955,3958,3960,3962,3965,3967,3969,3971,3973,3975],{"class":859,"line":3952},21,[857,3954,3697],{"class":872},[857,3956,3957],{"class":1705}," frame",[857,3959,3703],{"class":863},[857,3961,3124],{"class":863},[857,3963,3964],{"class":3308}," output",[857,3966,879],{"class":863},[857,3968,3706],{"class":863},[857,3970,3856],{"class":1705},[857,3972,1709],{"class":863},[857,3974,3799],{"class":1705},[857,3976,3977],{"class":863}," }\n",[857,3979,3981,3983,3985,3987],{"class":859,"line":3980},22,[857,3982,3901],{"class":863},[857,3984,3856],{"class":1705},[857,3986,3703],{"class":863},[857,3988,3989],{"class":863}," ''\n",[857,3991,3993,3995,3997,3999,4002,4004,4007],{"class":859,"line":3992},23,[857,3994,3901],{"class":863},[857,3996,3868],{"class":1705},[857,3998,349],{"class":863},[857,4000,4001],{"class":1701},"onLineDone",[857,4003,1757],{"class":3308},[857,4005,4006],{"class":1705},"frame",[857,4008,3186],{"class":3308},[857,4010,4012],{"class":859,"line":4011},24,[857,4013,4014],{"class":863},"  }\n",[857,4016,4018],{"class":859,"line":4017},25,[857,4019,3147],{"emptyLinePlaceholder":623},[857,4021,4023],{"class":859,"line":4022},26,[857,4024,4025],{"class":3396},"  // What remains in `pending` is either partial output\n",[857,4027,4029],{"class":859,"line":4028},27,[857,4030,4031],{"class":3396},"  // or a started marker that continues in the next chunk.\n",[857,4033,4035],{"class":859,"line":4034},28,[857,4036,961],{"class":863},[13,4038,4039,4040,4043,4044,4047,4048,4051],{},"The subtle point is handling ",[71,4041,4042],{},"partial markers",". A PTY chunk can end in the middle of the marker — bash wrote the beginning, the rest arrives with the next ",[29,4045,4046],{},"data"," event. If you advance the pending buffer in between (for example, trimming to the last newline), you destroy the partial marker and detection fails. The solution: ",[71,4049,4050],{},"keep all unmatched bytes in the pending buffer"," until either the marker arrives completely or the stream ends.",[2961,4053,4055],{"id":4054},"the-bootstrap-phase","The Bootstrap Phase",[13,4057,4058,4059,4061,4062,4065,4066,4069],{},"One last detail that ruins a first run: when you start bash, ",[29,4060,3029],{}," is loaded first. This often produces output — MOTDs, shell init messages, ",[29,4063,4064],{},"nvm"," status prints, all sorts of things. The first marker you see is ",[71,4067,4068],{},"not"," the end of a user command. It's the end of the startup process.",[13,4071,4072],{},"This means: two phases in the state.",[736,4074,4075,4081],{},[236,4076,4077,4080],{},[71,4078,4079],{},"Phase 1 — Bootstrap:"," wait for the first marker. Discard everything that came before it (startup noise). Signal to the consumer \"bash is ready.\"",[236,4082,4083,4086,4087,4089],{},[71,4084,4085],{},"Phase 2 — Normal:"," every subsequent marker is the end of a user command. Frames go via ",[29,4088,4001],{}," to the consumer.",[13,4091,4092,4093,879],{},"In ape-shell this is a single boolean called ",[29,4094,4095],{},"readyForFirstLine",[304,4097,4099],{"className":1692,"code":4098,"language":1694,"meta":312,"style":312},"if (!this.readyForFirstLine) {\n  // Bootstrap prompt: discard startup noise, signal ready.\n  // onLineDone deliberately does NOT fire here — that would be\n  // a fake frame from the consumer's perspective.\n  this.readyForFirstLine = true\n  this.currentLineBuffer = ''\n  const resolve = this.awaitingInitialPrompt\n  this.awaitingInitialPrompt = null\n  if (resolve) resolve()\n  continue\n}\n\n// Real command end: deliver frame.\nconst frame = { output: this.currentLineBuffer, exitCode }\nthis.currentLineBuffer = ''\nthis.events.onLineDone(frame)\n",[29,4100,4101,4115,4120,4125,4130,4142,4152,4167,4179,4196,4201,4205,4209,4214,4241,4252],{"__ignoreMap":312},[857,4102,4103,4105,4107,4110,4113],{"class":859,"line":860},[857,4104,3537],{"class":1720},[857,4106,1876],{"class":1705},[857,4108,4109],{"class":863},"!this.",[857,4111,4112],{"class":1705},"readyForFirstLine) ",[857,4114,864],{"class":863},[857,4116,4117],{"class":859,"line":606},[857,4118,4119],{"class":3396},"  // Bootstrap prompt: discard startup noise, signal ready.\n",[857,4121,4122],{"class":859,"line":893},[857,4123,4124],{"class":3396},"  // onLineDone deliberately does NOT fire here — that would be\n",[857,4126,4127],{"class":859,"line":914},[857,4128,4129],{"class":3396},"  // a fake frame from the consumer's perspective.\n",[857,4131,4132,4134,4136,4138],{"class":859,"line":938},[857,4133,3664],{"class":863},[857,4135,4095],{"class":1705},[857,4137,3703],{"class":863},[857,4139,4141],{"class":4140},"sfNiH"," true\n",[857,4143,4144,4146,4148,4150],{"class":859,"line":958},[857,4145,3664],{"class":863},[857,4147,3856],{"class":1705},[857,4149,3703],{"class":863},[857,4151,3989],{"class":863},[857,4153,4154,4157,4160,4162,4164],{"class":859,"line":3377},[857,4155,4156],{"class":872},"  const",[857,4158,4159],{"class":1705}," resolve",[857,4161,3703],{"class":863},[857,4163,3706],{"class":863},[857,4165,4166],{"class":1705},"awaitingInitialPrompt\n",[857,4168,4169,4171,4174,4176],{"class":859,"line":3393},[857,4170,3664],{"class":863},[857,4172,4173],{"class":1705},"awaitingInitialPrompt",[857,4175,3703],{"class":863},[857,4177,4178],{"class":863}," null\n",[857,4180,4181,4184,4186,4189,4191,4193],{"class":859,"line":3400},[857,4182,4183],{"class":1720},"  if",[857,4185,1876],{"class":3308},[857,4187,4188],{"class":1705},"resolve",[857,4190,3690],{"class":3308},[857,4192,4188],{"class":1701},[857,4194,4195],{"class":3308},"()\n",[857,4197,4198],{"class":859,"line":3433},[857,4199,4200],{"class":1720},"  continue\n",[857,4202,4203],{"class":859,"line":3439},[857,4204,961],{"class":863},[857,4206,4207],{"class":859,"line":3466},[857,4208,3147],{"emptyLinePlaceholder":623},[857,4210,4211],{"class":859,"line":3483},[857,4212,4213],{"class":3396},"// Real command end: deliver frame.\n",[857,4215,4216,4219,4222,4224,4226,4228,4230,4232,4234,4236,4239],{"class":859,"line":3499},[857,4217,4218],{"class":872},"const",[857,4220,4221],{"class":1705}," frame ",[857,4223,3158],{"class":863},[857,4225,3124],{"class":863},[857,4227,3964],{"class":3308},[857,4229,879],{"class":863},[857,4231,3706],{"class":863},[857,4233,3856],{"class":1705},[857,4235,1709],{"class":863},[857,4237,4238],{"class":1705}," exitCode ",[857,4240,961],{"class":863},[857,4242,4243,4245,4248,4250],{"class":859,"line":3505},[857,4244,3152],{"class":863},[857,4246,4247],{"class":1705},"currentLineBuffer ",[857,4249,3158],{"class":863},[857,4251,3989],{"class":863},[857,4253,4254,4256,4258,4260,4262],{"class":859,"line":3887},[857,4255,3152],{"class":863},[857,4257,3868],{"class":1705},[857,4259,349],{"class":863},[857,4261,4001],{"class":1701},[857,4263,4264],{"class":1705},"(frame)\n",[13,4266,4267],{},"Without this separation, the consumer gets a broken frame at startup with all the rcfile noise as \"output\" and an arbitrary exit code. This is the kind of bug you notice at the fifth user, and then the cause is hard to find.",[41,4269,4271],{"id":4270},"one-application-ape-shells-ptybridge","One Application: ape-shell's PtyBridge",[13,4273,4274,4275,4281,4282,4289],{},"I use this pattern in ",[574,4276,4279],{"href":4277,"rel":4278},"https://github.com/openape-ai/openape/tree/main/packages/apes",[595],[29,4280,2403],{},", a grant-secured shell wrapper I'm building for AI agent workflows. The concrete implementation lives in ",[574,4283,4286],{"href":4284,"rel":4285},"https://github.com/openape-ai/openape/blob/main/packages/apes/src/shell/pty-bridge.ts",[595],[29,4287,4288],{},"packages/apes/src/shell/pty-bridge.ts"," — a bit more than 200 lines of TypeScript that cover the complete cycle: spawn, bootstrap, line detection, streaming output, exit handling.",[13,4291,4292,4293,4296,4297,4300],{},"In ape-shell, between user input and bash there's a grant check. The PtyBridge itself knows nothing about it — it only cares about the clean abstraction ",[20,4294,4295],{},"\"bash is done with this line, here's the output and the exit code.\""," The grant layer above decides whether a line even reaches ",[29,4298,4299],{},"writeLine",". The separation of concerns is one reason the pattern feels so natural: marker detection is a universal problem, grant logic is specific.",[41,4302,4304],{"id":4303},"closing","Closing",[13,4306,4307],{},"The pattern isn't new. Terminal emulators, REPL orchestrators, shell testing frameworks — they've all solved some variant of this for decades. expect, pexpect, bash-it's test suite, IPython kernel, Jupyter frontends: they all have a marker trick somewhere. But it's rarely described explicitly. Most developers building a shell wrapper stumble onto the solution themselves, sometimes only after the third naive implementation with timeouts and newline heuristics.",[13,4309,4310],{},"If you ever build a tool that needs to control a persistent shell (or any other REPL with a prompt-based \"ready\" signal): this is probably the pattern you're looking for. Boring infrastructure in its best sense — invisible when it works, critical when it's missing.",[1455,4312,4313],{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}",{"title":312,"searchDepth":606,"depth":606,"links":4315},[4316,4317,4324,4325,4332,4333],{"id":2892,"depth":606,"text":2893},{"id":2958,"depth":606,"text":2959,"children":4318},[4319,4320,4321,4322,4323],{"id":2963,"depth":893,"text":2964},{"id":2984,"depth":893,"text":2985},{"id":2995,"depth":893,"text":2996},{"id":3022,"depth":893,"text":3023},{"id":3040,"depth":893,"text":3041},{"id":3057,"depth":606,"text":3058},{"id":3084,"depth":606,"text":3085,"children":4326},[4327,4328,4329,4330,4331],{"id":3088,"depth":893,"text":3089},{"id":3213,"depth":893,"text":3214},{"id":3549,"depth":893,"text":3550},{"id":3634,"depth":893,"text":3635},{"id":4054,"depth":893,"text":4055},{"id":4270,"depth":606,"text":4271},{"id":4303,"depth":606,"text":4304},"2026-04-10","When you build a shell wrapper that controls a persistent bash across multiple commands, you stumble into a surprisingly deep question: when is bash done with the current command? A small deep dive into prompt markers, PROMPT_COMMAND, and why the answer isn't to read PS1 — but to override it.",{},"/blog/en/how-do-i-know-when-bash-is-done",{"title":2856,"description":4335},"blog/en/how-do-i-know-when-bash-is-done",[4341,4342,4343,4344,628],"Systems Programming","Shell","Bash","Technical Deep Dive","how-do-i-know-when-bash-is-done","eJmEaF23pp_DwmnDfGam4W3SgD59-xwxIT6ruQuzBtU",{"id":4348,"title":4349,"author":8,"body":4350,"date":4657,"description":4658,"draft":620,"extension":621,"image":3,"meta":4659,"navigation":623,"path":4660,"seo":4661,"stem":4662,"tags":4663,"translationKey":4665,"__hash__":4666},"blog_en/blog/en/from-login-module-to-a-protocol.md","From a Login Module to a Protocol",{"type":10,"value":4351,"toc":4647},[4352,4355,4358,4361,4365,4371,4374,4385,4396,4400,4406,4413,4423,4427,4430,4433,4439,4449,4453,4459,4466,4473,4477,4480,4487,4491,4498,4501,4521,4524,4528,4531,4600,4610,4613,4617,4620,4623,4630,4633,4635,4644],[13,4353,4354],{},"I wanted to build a login module.",[13,4356,4357],{},"That was back in November. A Nuxt module that lets web apps support WebAuthn passkeys instead of passwords. Small, focused, one job. One weekend, then done.",[13,4359,4360],{},"That was the plan, anyway.",[41,4362,4364],{"id":4363},"problem-1-who-is-the-identity-provider","Problem 1: Who is the identity provider?",[13,4366,4367,4368],{},"The moment you implement WebAuthn, you stumble over an unassuming question: ",[71,4369,4370],{},"how does a service provider know which identity provider to talk to?",[13,4372,4373],{},"With Auth0 or Clerk the answer is easy: you registered with the provider, the provider is hardcoded. But the moment you think decentralized, the resolution is missing. A user types in their email address — and then?",[13,4375,4376,4377,4380,4381,4384],{},"The answer has been sitting around since 1983: ",[71,4378,4379],{},"DNS",". Every email address has a domain. Every domain can have TXT records. So I built DNS discovery: a TXT record at ",[29,4382,4383],{},"_ddisa.example.com"," points to the responsible identity provider. An email address is enough — DNS does the rest.",[13,4386,4387,4388,4391,4392,4395],{},"The login module became two: ",[29,4389,4390],{},"@openape/core"," for DNS resolution, ",[29,4393,4394],{},"@openape/auth"," for the actual WebAuthn flow.",[41,4397,4399],{"id":4398},"problem-2-but-how-does-an-agent-even-authenticate","Problem 2: But how does an agent even authenticate?",[13,4401,4402,4403],{},"Around the same time, my own projects started using AI agents seriously. And before I could even think about permissions, there was a much more basic question: ",[71,4404,4405],{},"passkeys need a finger. Agents don't have fingers. How is the thing supposed to sign in at all?",[13,4407,4408,4409,4412],{},"So I extended the auth flow with a second path: ",[71,4410,4411],{},"Ed25519 challenge-response, essentially like SSH keys."," The agent holds a private key, the IdP holds the public key, the IdP issues a challenge, the agent signs. Same pattern as WebAuthn — just without the browser and without a human in the loop.",[13,4414,4415,4416,4419,4420,4422],{},"And something nice happens: ",[71,4417,4418],{},"at the protocol level, the distinction between human and agent disappears."," Both have an identity at the IdP. Both authenticate via the same scheme. Both can use the same CLI (",[29,4421,843],{},"). The only difference: the human has a passkey on a laptop, the agent has an Ed25519 key on disk.",[41,4424,4426],{"id":4425},"problem-3-ok-its-signed-in-what-is-it-actually-allowed-to-do","Problem 3: OK it's signed in — what is it actually allowed to do?",[13,4428,4429],{},"The agent can log in. Fine. But can it just do anything now? Of course not. I want granular control — read this email yes, delete that one no, review this code yes, merge it directly no.",[13,4431,4432],{},"OAuth would be the obvious answer for authorization. But OAuth was designed for humans — authorization code flow, browser redirect, the whole dance. For a background agent it feels wrong.",[13,4434,4435,4436,4438],{},"So I built ",[71,4437,216],{},". A grant is a pre-approved JWT that allows an agent to perform a specific action. Granular, time-limited, revocable at any time. I approve once with my passkey — the agent can then act without further interaction, but only within the scope it was given.",[13,4440,4441,4442,4444,4445,4448],{},"Two packages became four: ",[29,4443,600],{}," joined the family, then ",[29,4446,4447],{},"@openape/proxy"," as an HTTP gateway for agents.",[41,4450,4452],{"id":4451},"problem-4-how-does-an-agent-sign-in-on-my-behalf","Problem 4: How does an agent sign in on my behalf?",[13,4454,4455,4456],{},"Grants answered \"the agent is allowed to do exactly this one action.\" But what if an agent needs to work at a service like me, for hours or days? ",[71,4457,4458],{},"Not a grant per call, but a session — under my identity.",[13,4460,4461,4462,4465],{},"Grants couldn't express that. A grant is a ticket for a single call, not a login. So it became its own protocol building block: ",[71,4463,4464],{},"delegation",". I authorize my agent once to sign in to a service on my behalf — and it then runs with its own session, as me, but with a clear audit trail: \"This wasn't Patrick himself, this was Agent X on Patrick's behalf.\"",[13,4467,4468,4469,4472],{},"Built on top of RFC 8693 (Token Exchange) with the ",[29,4470,4471],{},"act"," claim from OAuth 2.0. Standards where possible, custom extensions where necessary.",[41,4474,4476],{"id":4475},"problem-5-where-does-the-key-material-live","Problem 5: Where does the key material live?",[13,4478,4479],{},"Up to that point, everything ran in the browser or on the server. But for an identity platform that's not enough. Keys belong on the user's device, not in some server-side storage. So a desktop app joined the picture — Tauri v2, Vue 3 frontend, Rust backend. Plus a Rust CLI for power users and server setups.",[13,4481,4482,4483,4486],{},"The desktop app picked up another job along the way: ",[71,4484,4485],{},"it orchestrates AI agents as isolated OS users",". Each agent runs in its own user account, with its own permissions, in its own environment. That's no longer \"an identity module\" — that's infrastructure for the next generation of AI-driven workflows.",[41,4488,4490],{"id":4489},"problem-6-how-do-you-write-this-down","Problem 6: How do you write this down?",[13,4492,4493,4494,4497],{},"At some point I had 10 packages, 2 Nuxt modules, 6 apps, a desktop app, a CLI, and no specification. A protocol needs a specification, though — otherwise it's just code. So I started writing ",[71,4495,4496],{},"DDISA",": DNS-Discoverable Identity & Service Authorization.",[13,4499,4500],{},"Three documents:",[736,4502,4503,4509,4515],{},[236,4504,4505,4508],{},[71,4506,4507],{},"Core",": DNS discovery, OIDC extensions, WebAuthn and Ed25519 auth flows, token format",[236,4510,4511,4514],{},[71,4512,4513],{},"Grants",": Grant-based authorization REST API, AuthZ-JWT, polling model",[236,4516,4517,4520],{},[71,4518,4519],{},"Delegation",": Delegation protocol on top of RFC 8693",[13,4522,4523],{},"Plus JSON Schemas (Draft 2020-12) for every data format, plus complete HTTP examples for every flow. Compliance levels for implementations: Core, Core+Grants, Core+Grants+Delegation.",[41,4525,4527],{"id":4526},"where-this-stands-today","Where this stands today",[13,4529,4530],{},"Today, on April 9, 2026, OpenApe looks like this:",[1123,4532,4533,4543],{},[1126,4534,4535],{},[1129,4536,4537,4540],{},[1132,4538,4539],{},"Component",[1132,4541,4542],{},"Description",[1141,4544,4545,4555,4565,4575,4590],{},[1129,4546,4547,4552],{},[1146,4548,4549],{},[71,4550,4551],{},"Protocol",[1146,4553,4554],{},"3 specs (Core, Grants, Delegation), JSON Schemas, full examples",[1129,4556,4557,4562],{},[1146,4558,4559],{},[71,4560,4561],{},"Monorepo",[1146,4563,4564],{},"10 npm packages, 2 Nuxt modules, 6 deployed apps",[1129,4566,4567,4572],{},[1146,4568,4569],{},[71,4570,4571],{},"Desktop App",[1146,4573,4574],{},"Tauri v2, orchestrates AI agents as isolated OS users",[1129,4576,4577,4582],{},[1146,4578,4579],{},[71,4580,4581],{},"CLI",[1146,4583,4584,4586,4587,4589],{},[29,4585,843],{}," for grant management, ",[29,4588,2403],{}," as a grant-secured shell",[1129,4591,4592,4597],{},[1146,4593,4594],{},[71,4595,4596],{},"Free IdP",[1146,4598,4599],{},"Hosted identity provider, free to use",[13,4601,4602,4603,4605,4606,4609],{},"This morning I committed ",[29,4604,2403],{}," — a shell replacement that pipes every command through the grant system. ",[29,4607,4608],{},"ape-shell -c \"git status\""," requests a grant that's valid for the session. Subsequent commands reuse it. Zero-latency re-execution with human control.",[13,4611,4612],{},"Back in November I wanted to build a login module.",[41,4614,4616],{"id":4615},"what-i-learned","What I learned",[13,4618,4619],{},"The most important projects don't get drawn on a whiteboard. They emerge when you solve a concrete problem and notice the problem was hiding another problem. And the next one. And the one after that.",[13,4621,4622],{},"If back in November I had made \"a plan for a decentralized identity protocol,\" I would have overwhelmed myself. Instead I built a login module. That worked. Then the next piece. That worked too. And so on, until the result was bigger than the original plan.",[13,4624,4625,4626,4629],{},"The only reason it worked: ",[71,4627,4628],{},"every step was small and concrete on its own."," The vision only emerged in hindsight. It wasn't a starting point, it was a consequence.",[13,4631,4632],{},"That's maybe the part of \"building in public\" that's actually valuable: not the public showing, but the public admission that you didn't know from day one what you were building. You found out by building. At least that's how it was for me.",[584,4634],{},[13,4636,4637,4638,4643],{},"OpenApe is open source. The protocol spec, the code, the apps — all on ",[574,4639,4642],{"href":4640,"rel":4641},"https://github.com/openape-ai",[595],"github.com/openape-ai",". If you're interested in decentralized identity, AI agent authorization, or just in interesting protocol design: take a look, open issues, write to me.",[13,4645,4646],{},"What was the last project that grew beyond what you planned?",{"title":312,"searchDepth":606,"depth":606,"links":4648},[4649,4650,4651,4652,4653,4654,4655,4656],{"id":4363,"depth":606,"text":4364},{"id":4398,"depth":606,"text":4399},{"id":4425,"depth":606,"text":4426},{"id":4451,"depth":606,"text":4452},{"id":4475,"depth":606,"text":4476},{"id":4489,"depth":606,"text":4490},{"id":4526,"depth":606,"text":4527},{"id":4615,"depth":606,"text":4616},"2026-04-09","Back in November I wanted to build a Nuxt module for WebAuthn passkeys. Today OpenApe is 10 npm packages, 2 Nuxt modules, a protocol spec, and a desktop app. A story about problems that hide other problems.",{},"/blog/en/from-login-module-to-a-protocol",{"title":4349,"description":4658},"blog/en/from-login-module-to-a-protocol",[628,4496,4664,631],"Decentralized Identity","from-login-module-to-protocol","CJ9Ldn_JfIAFTjIOtO-1-aJuaaNjCxYZbd3oadN0Mmc",1776970806600]