<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Luke Manning - Blog — OpenCode</title>
        <link>https://lukemanning.ie/</link>
        <description>Breaking things. Building things. Writing about it. (tag: OpenCode)</description>
        <lastBuildDate>Wed, 30 Sep 2026 12:46:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>All rights reserved 2026, Luke Manning</copyright>
        <atom:link href="https://lukemanning.ie/feeds/opencode.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[My Agent Closed Most of the Open Projex Issues in One Round Without Me Reading the Code]]></title>
            <link>https://lukemanning.ie/blog/my-agent-closed-most-projex-issues-in-one-round</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/my-agent-closed-most-projex-issues-in-one-round</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>On a Saturday afternoon in August, I told my agent to fix every open issue in the Projex repo and prepare a release. There were ten open. Some were HIGH priority. Some were LOW.</p>
<p>I went for a walk.</p>
<p>When I came back, the work had landed across three branches. Each subagent had worked in its own worktree on its own branch. The build was green on each one. The tests were green. The lint was green. The typecheck was green. The release manager subagent had drafted the changelog. Nine of the ten issues were closed. The tenth stayed open — it needed a real union restructure, not a mechanical fix.</p>
<p>I had not read a single line of the diff yet.</p>
<h2>What I actually asked for</h2>
<p>I had a backlog of small things in <a href="/blog/building-projex-retrospective">the Projex repo</a>. Tagged-union cleanups. Type tightening. Documentation gaps. A bug where one of the smart-grid props was a documented prop but a no-op at runtime. A redundancy where two functions with slightly different spellings did the same thing.</p>
<p>I described this to my main agent. The main agent looked at the issue tracker, saw the labels (<code>bug</code>, <code>enhancement</code>, <code>documentation</code>), grouped them by file area, and <a href="/blog/opencode-subagent-permissions-ordering-trap">dispatched three subagents</a> in parallel.</p>
<p>One subagent got the package.json + bundling issues. One got the type-system + tagged-union issues. One got the documentation + test-coverage issues. Each one worked in a separate worktree on a separate branch. Each one committed locally and reported back. A fourth subagent, the release manager, handled release prep alongside them: the version bump and the changelog.</p>
<p>I watched the transcript. Mostly I stayed out of the way. I made tea.</p>
<h2>What the subagents actually did</h2>
<p>I saw the dispatch messages. I saw the report-back messages. I did not read the intermediate diffs. The subagents were set up to commit per-issue. Each commit was meant to be independently reviewable.</p>
<p>A few things I noticed in the report-backs:</p>
<ul>
<li>One subagent caught a redundancy I hadn't seen. Two exported functions, <code>normalizeStats</code> and <code>normaliseStats</code>, with the American and British spellings. Both did the same thing. The codebase had drifted to the British spelling in the actual logic. The American spelling was the alias. Both were exported. The subagent deprecated the American one with a JSDoc tag and updated the docs to point at the British spelling.</li>
<li>One subagent found a related issue while fixing another one. While narrowing the <code>ProjectStats</code> union, it noticed <code>FetchProjectDataResult.commits</code> was using <code>undefined</code> while sibling fields used <code>null</code>. The subagent opened a new issue and included the fix in the same branch.</li>
<li>One subagent flagged a peer-dependency problem I had been ignoring for two months. The CLI packages (<code>ts-morph</code>, <code>chalk</code>, <code>@inquirer/prompts</code>, <code>commander</code>) were installed by every consumer, even ones who only imported the components. The subagent moved them to optional <code>peerDependencies</code> so consumers importing only components stopped dragging in the CLI bundle.</li>
</ul>
<p>None of these were in my original brief. The subagents went past the edges of what I asked for, in the direction of "things that were obviously wrong in the same file area."</p>
<h2>Where I read the code</h2>
<p>The first time I read any of the code was after all three subagents finished and opened their PRs. I skimmed the diffs before merging. Not a line-by-line review. A sanity check.</p>
<p>I was looking for decisions the AI made without asking me. Function names I wouldn't have picked. Behaviour that wasn't in the brief. Edits that touched code outside the file area I asked about. The kind of things a real code review catches, except I was reviewing the decisions, not the code.</p>
<p>Some of the diffs were four lines. Some were thirty. None of them were complex enough to need a real review. They were tagged-union narrowings, JSDoc additions, dependency relocations. The kind of work where you skim it once, you understand it, you move on.</p>
<p>If I'd skimmed each PR as it landed, I'd have read the rename with no idea the docs were about to change under it. Reading the batch, I could see the <code>normaliseStats</code> deprecation and the docs update pointing at it in the same sitting. The batch skim was faster than piecemeal would have been.</p>
<h2>Where I did intervene</h2>
<p>I didn't push back on any of the code. The three branches each shipped clean. I steered the architecture around the loop, not the code inside it.</p>
<p>The dispatch went out as three parallel <code>opencode run</code> invocations, not three subagents. I asked for that change when the opencode TUI failed on the first attempt and the right path was to skip the interactive layer. I also argued for splitting the release prep out from the fix work, because trying to do both in the same dispatch kept blocking on the release-manager hitting its timeout before the fix branches landed.</p>
<h2>What this loop replaced</h2>
<p>My previous loop was one PR at a time. I'd describe an issue to the agent. The agent would open a PR. I'd skim it, sanity-check the decisions, merge or push back. One issue, one PR, one round of skimming. Repeat.</p>
<p>For this kind of small mechanical work, that's fine. It works. But the context switching adds up. Each PR is its own session — its own dispatch, its own transcript, its own review pass. The overhead is small per PR and large per backlog.</p>
<p>The new loop:</p>
<ol>
<li>Describe the backlog.</li>
<li>Wait.</li>
<li>Skim the batch of PRs.</li>
<li>Push the release prep.</li>
</ol>
<p>The release prep runs alongside the fix work instead of after it. One description covers all of them.</p>
<p>I want to be careful about what I'm claiming here. I'm not claiming the subagents did better work than the agent would have done one PR at a time. Most of these issues were mechanical. The interesting decisions — which redundancy to deprecate, which naming to standardize — the agent would have surfaced them either way, given the brief. What I'm claiming is that the per-PR overhead moved out of my hands and the interesting decisions stayed in my hands.</p>
<h2>Reading at the end, not in the middle</h2>
<p>I did not read the code while it was being written.</p>
<p>In the old loop, I skimmed each PR after the agent opened it. One PR at a time.</p>
<p>In the new loop, the skim happened after the writing finished across all three PRs. The subagents were the feedback loop during the work. I was the feedback loop at the end.</p>
<p>There's a different cost structure. A wrong fix in the old loop was caught in the per-PR skim, or it shipped. A wrong fix in the new loop is caught in the batch skim, or it ships.</p>
<p>I shipped none of the wrong fixes in this batch. The issues were mechanical and the code area was small. If I'd asked the subagents to redesign the <code>normalise</code> function, I would have read every line, pushed back, rewritten pieces.</p>
<p>The loop works for the kind of work that has a clear right answer. The loop does not work for the kind of work that needs taste. I haven't found the line yet.</p>
<p>For this batch, the line was "moves stuff around, adds JSDoc, narrows types." Below the line, I delegated. Above the line, I didn't. The line is in a different place than I would have guessed.</p>
<h2>Running it again</h2>
<p>I'm going to run this loop again. On a different repo. On a different kind of work.</p>
<p>I want to see what happens when the issues aren't mechanical. I want to see where the line moves. I want to see what kinds of work I delegate that I later wish I hadn't, and what kinds I keep that the loop could have handled.</p>
<p>I'm not going to delegate design decisions. I'm not going to delegate <a href="/blog/i-shipped-a-library-now-what">"what should this library do"</a>. I'm going to delegate "make this library do what it already says it does, correctly."</p>
<p>The batch skim at the end is non-negotiable. That's the part I own.</p>]]></description>
            <content:encoded><![CDATA[<p>On a Saturday afternoon in August, I told my agent to fix every open issue in the Projex repo and prepare a release. There were ten open. Some were HIGH priority. Some were LOW.</p>
<p>I went for a walk.</p>
<p>When I came back, the work had landed across three branches. Each subagent had worked in its own worktree on its own branch. The build was green on each one. The tests were green. The lint was green. The typecheck was green. The release manager subagent had drafted the changelog. Nine of the ten issues were closed. The tenth stayed open — it needed a real union restructure, not a mechanical fix.</p>
<p>I had not read a single line of the diff yet.</p>
<h2>What I actually asked for</h2>
<p>I had a backlog of small things in <a href="/blog/building-projex-retrospective">the Projex repo</a>. Tagged-union cleanups. Type tightening. Documentation gaps. A bug where one of the smart-grid props was a documented prop but a no-op at runtime. A redundancy where two functions with slightly different spellings did the same thing.</p>
<p>I described this to my main agent. The main agent looked at the issue tracker, saw the labels (<code>bug</code>, <code>enhancement</code>, <code>documentation</code>), grouped them by file area, and <a href="/blog/opencode-subagent-permissions-ordering-trap">dispatched three subagents</a> in parallel.</p>
<p>One subagent got the package.json + bundling issues. One got the type-system + tagged-union issues. One got the documentation + test-coverage issues. Each one worked in a separate worktree on a separate branch. Each one committed locally and reported back. A fourth subagent, the release manager, handled release prep alongside them: the version bump and the changelog.</p>
<p>I watched the transcript. Mostly I stayed out of the way. I made tea.</p>
<h2>What the subagents actually did</h2>
<p>I saw the dispatch messages. I saw the report-back messages. I did not read the intermediate diffs. The subagents were set up to commit per-issue. Each commit was meant to be independently reviewable.</p>
<p>A few things I noticed in the report-backs:</p>
<ul>
<li>One subagent caught a redundancy I hadn't seen. Two exported functions, <code>normalizeStats</code> and <code>normaliseStats</code>, with the American and British spellings. Both did the same thing. The codebase had drifted to the British spelling in the actual logic. The American spelling was the alias. Both were exported. The subagent deprecated the American one with a JSDoc tag and updated the docs to point at the British spelling.</li>
<li>One subagent found a related issue while fixing another one. While narrowing the <code>ProjectStats</code> union, it noticed <code>FetchProjectDataResult.commits</code> was using <code>undefined</code> while sibling fields used <code>null</code>. The subagent opened a new issue and included the fix in the same branch.</li>
<li>One subagent flagged a peer-dependency problem I had been ignoring for two months. The CLI packages (<code>ts-morph</code>, <code>chalk</code>, <code>@inquirer/prompts</code>, <code>commander</code>) were installed by every consumer, even ones who only imported the components. The subagent moved them to optional <code>peerDependencies</code> so consumers importing only components stopped dragging in the CLI bundle.</li>
</ul>
<p>None of these were in my original brief. The subagents went past the edges of what I asked for, in the direction of "things that were obviously wrong in the same file area."</p>
<h2>Where I read the code</h2>
<p>The first time I read any of the code was after all three subagents finished and opened their PRs. I skimmed the diffs before merging. Not a line-by-line review. A sanity check.</p>
<p>I was looking for decisions the AI made without asking me. Function names I wouldn't have picked. Behaviour that wasn't in the brief. Edits that touched code outside the file area I asked about. The kind of things a real code review catches, except I was reviewing the decisions, not the code.</p>
<p>Some of the diffs were four lines. Some were thirty. None of them were complex enough to need a real review. They were tagged-union narrowings, JSDoc additions, dependency relocations. The kind of work where you skim it once, you understand it, you move on.</p>
<p>If I'd skimmed each PR as it landed, I'd have read the rename with no idea the docs were about to change under it. Reading the batch, I could see the <code>normaliseStats</code> deprecation and the docs update pointing at it in the same sitting. The batch skim was faster than piecemeal would have been.</p>
<h2>Where I did intervene</h2>
<p>I didn't push back on any of the code. The three branches each shipped clean. I steered the architecture around the loop, not the code inside it.</p>
<p>The dispatch went out as three parallel <code>opencode run</code> invocations, not three subagents. I asked for that change when the opencode TUI failed on the first attempt and the right path was to skip the interactive layer. I also argued for splitting the release prep out from the fix work, because trying to do both in the same dispatch kept blocking on the release-manager hitting its timeout before the fix branches landed.</p>
<h2>What this loop replaced</h2>
<p>My previous loop was one PR at a time. I'd describe an issue to the agent. The agent would open a PR. I'd skim it, sanity-check the decisions, merge or push back. One issue, one PR, one round of skimming. Repeat.</p>
<p>For this kind of small mechanical work, that's fine. It works. But the context switching adds up. Each PR is its own session — its own dispatch, its own transcript, its own review pass. The overhead is small per PR and large per backlog.</p>
<p>The new loop:</p>
<ol>
<li>Describe the backlog.</li>
<li>Wait.</li>
<li>Skim the batch of PRs.</li>
<li>Push the release prep.</li>
</ol>
<p>The release prep runs alongside the fix work instead of after it. One description covers all of them.</p>
<p>I want to be careful about what I'm claiming here. I'm not claiming the subagents did better work than the agent would have done one PR at a time. Most of these issues were mechanical. The interesting decisions — which redundancy to deprecate, which naming to standardize — the agent would have surfaced them either way, given the brief. What I'm claiming is that the per-PR overhead moved out of my hands and the interesting decisions stayed in my hands.</p>
<h2>Reading at the end, not in the middle</h2>
<p>I did not read the code while it was being written.</p>
<p>In the old loop, I skimmed each PR after the agent opened it. One PR at a time.</p>
<p>In the new loop, the skim happened after the writing finished across all three PRs. The subagents were the feedback loop during the work. I was the feedback loop at the end.</p>
<p>There's a different cost structure. A wrong fix in the old loop was caught in the per-PR skim, or it shipped. A wrong fix in the new loop is caught in the batch skim, or it ships.</p>
<p>I shipped none of the wrong fixes in this batch. The issues were mechanical and the code area was small. If I'd asked the subagents to redesign the <code>normalise</code> function, I would have read every line, pushed back, rewritten pieces.</p>
<p>The loop works for the kind of work that has a clear right answer. The loop does not work for the kind of work that needs taste. I haven't found the line yet.</p>
<p>For this batch, the line was "moves stuff around, adds JSDoc, narrows types." Below the line, I delegated. Above the line, I didn't. The line is in a different place than I would have guessed.</p>
<h2>Running it again</h2>
<p>I'm going to run this loop again. On a different repo. On a different kind of work.</p>
<p>I want to see what happens when the issues aren't mechanical. I want to see where the line moves. I want to see what kinds of work I delegate that I later wish I hadn't, and what kinds I keep that the loop could have handled.</p>
<p>I'm not going to delegate design decisions. I'm not going to delegate <a href="/blog/i-shipped-a-library-now-what">"what should this library do"</a>. I'm going to delegate "make this library do what it already says it does, correctly."</p>
<p>The batch skim at the end is non-negotiable. That's the part I own.</p>]]></content:encoded>
            <category>ai</category>
            <category>opencode</category>
            <category>projex</category>
            <category>github</category>
        </item>
        <item>
            <title><![CDATA[Straico Has Great Models But No Streaming, So I Built a Proxy]]></title>
            <link>https://lukemanning.ie/blog/building-straico-api-proxy</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/building-straico-api-proxy</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I use <a href="/blog/opencode-nested-slash-commands-architecture">OpenCode</a> as my main AI coding tool. I switched from Claude Code after <a href="/blog/anthropic-open-source-walled-garden-clawdbot-opencode">Anthropic started going after open source projects</a> and I kept hitting session limits on my subscription.</p>
<p>OpenCode works with any OpenAI-compatible API. Straico gives me access to Claude, GPT, Gemini, DeepSeek, and a bunch more through a single API key. Cheap too. Problem is, Straico's API is missing two things OpenCode needs: streaming responses and function calling.</p>
<p>Without streaming, OpenCode just hangs. Never gets a response. But Straico keeps eating tokens on their end anyway. Without function calling, the AI can't use tools like reading files or running bash commands. Both are non-negotiable for an agentic coding tool.</p>
<p>So I built a proxy. It sits between OpenCode and Straico, translating requests and responses to fill in the gaps.</p>
<pre><code>OpenCode
  → localhost:8000 (my proxy)
    → Straico API
</code></pre>
<p>What started as "just simulate streaming and inject tool definitions" turned into a surprisingly full-featured thing. The codebase is at <a href="https://github.com/ManningWorks/DOAI-Proxy">github.com/ManningWorks/DOAI-Proxy</a>.</p>
<h2>The Architecture I Ended Up With</h2>
<p>I didn't start with a provider pattern. I started with four files: <code>server.js</code>, <code>streaming.js</code>, <code>tools.js</code>, <code>utils.js</code>. But once I started thinking about adding other providers down the line (OpenAI direct, Anthropic direct), I refactored into something cleaner.</p>
<p>The provider pattern lives in <code>providers/</code>. <code>BaseProvider</code> is an abstract class that handles the interface contract and retry logic. <code>StraicoProvider</code> extends it with Straico-specific request/response transformation. <code>ProviderFactory</code> instantiates the right one based on the <code>PROVIDER_TYPE</code> env var.</p>
<p>Right now only Straico exists, but the factory already has stubs for OpenAI and Anthropic. The <code>ADDING_PROVIDERS.md</code> doc in the repo lays out how to add a new one.</p>
<p>The other modules:</p>
<ul>
<li><code>server.js</code> - Express server, routing, auth, request lifecycle</li>
<li><code>streaming.js</code> - SSE simulation with two modes</li>
<li><code>tools.js</code> - Tool injection and response parsing</li>
<li><code>utils.js</code> - Logging, formatting, log rotation</li>
<li><code>utils/model-limits.js</code> - Fetches context limits from Straico's API</li>
<li><code>summarizer.js</code> - Conversation summarization for long sessions</li>
<li><code>scripts/sync-opencode-config.js</code> - Syncs model list to OpenCode config</li>
</ul>
<h2>Streaming Without Streaming</h2>
<p>Straico returns the full response at once. No SSE. No chunks. The proxy has to fake it.</p>
<p>Two modes: <code>none</code> and <code>smart</code>.</p>
<p><code>none</code> is what I'd recommend as default. It sends the entire response in one SSE chunk, then the <code>[DONE]</code> marker. Fast, no formatting issues, still technically SSE.</p>
<p><code>smart</code> is more interesting. It splits the response into chunks with delays to simulate real streaming. The naive approach is <code>responseText.match(new RegExp('.{1,15}', 'g'))</code> and that kind of works. But it breaks markdown. Split mid-bold, mid-code-block, mid-backtick and the rendering glitches.</p>
<p>So <code>smartChunkText()</code> in <code>streaming.js</code> looks for safe boundaries. It prefers splitting on newlines, then whitespace. It also checks for markdown delimiters (<code>**</code>, <code>__</code>, <code>\</code>``<code>, `` </code> ``) and extends the chunk to avoid splitting them. There's a max size limit (<code>targetSize * 10</code>) to prevent infinite extension.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// streaming.js - simplified version of the boundary logic</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">let</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> end; i </span><span style="color:#F97583">></span><span style="color:#E1E4E8"> start; i</span><span style="color:#F97583">--</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (text[i] </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '</span><span style="color:#79B8FF">\n</span><span style="color:#9ECBFF">'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> delim</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> [</span><span style="color:#9ECBFF">'**'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'__'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'```'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'`'</span><span style="color:#E1E4E8">]) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> delimStart</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> text.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(delim, start);</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (delimStart </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> delimStart </span><span style="color:#F97583">&#x3C;</span><span style="color:#E1E4E8"> end) {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> delimEnd</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> delimStart </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> delim.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (delimEnd </span><span style="color:#F97583">></span><span style="color:#E1E4E8"> end) {</span></span>
<span class="line"><span style="color:#F97583">      return</span><span style="color:#E1E4E8"> Math.</span><span style="color:#B392F0">min</span><span style="color:#E1E4E8">(delimEnd, text.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">, start </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> maxSize);</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Default is 15 characters per chunk with 80ms delay. That feels about right for most models. Configurable via <code>STREAM_CHUNK_SIZE</code> and <code>STREAM_DELAY_MS</code> env vars.</p>
<p>I set <code>STREAM_MODE=none</code> as the recommended default. <code>smart</code> works but it's more of a showcase thing. The boundary detection catches most cases but I wouldn't trust it with complex nested markdown.</p>
<h2>Function Calling via Prompt Injection</h2>
<p>Straico doesn't support function calling natively. The workaround: inject tool definitions into the system prompt and parse the AI's response to detect tool calls.</p>
<p><code>injectToolsIntoSystem()</code> in <code>tools.js</code> appends a formatted list of available tools to the system message:</p>
<pre><code>You have access to the following tools:
- bash: Run bash commands
- read: Read file contents

When you need to use a tool, format your response like this:
TOOL_CALL: &#x3C;tool_name>
ARGUMENTS: &#x3C;json_arguments>
</code></pre>
<p>There's a sentinel comment (<code>&#x3C;!-- proxy-tools-injected --></code>) to prevent double-injection if the same messages get processed twice.</p>
<p>The tricky part is parsing. Different models output tool calls in different formats. I ended up with four parsers that run in sequence:</p>
<ol>
<li><strong>Minimax XML</strong> - <code>&#x3C;minimax:tool_call></code> with <code>&#x3C;invoke></code> tags</li>
<li><strong>Claude XML</strong> - <code>&#x3C;invoke name="..."></code> with <code>&#x3C;parameter_list></code> tags</li>
<li><strong>OpenAI Native</strong> - JSON with <code>"tool_calls": [...]</code> embedded in the response</li>
<li><strong>Text Format</strong> - The <code>TOOL_CALL: / ARGUMENTS:</code> format from the injection prompt</li>
</ol>
<p>Each parser tries to extract tool calls from the response text. The first one that succeeds wins. This was a gradual thing. I started with just the text format parser. Then Minimax models returned XML. Then Claude models returned different XML. Then some models returned JSON that looked like OpenAI's format. Four parsers later and it handles most cases.</p>
<p>The text format parser was the hardest to get right. Matching <code>TOOL_CALL: tool_name ARGUMENTS: {json}</code> seems simple until the JSON contains nested objects, strings with braces, or the model forgets the space between the tool name and ARGUMENTS. The implementation tracks brace depth to find where the JSON actually ends:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">let</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> argsStartIndex; i </span><span style="color:#F97583">&#x3C;</span><span style="color:#E1E4E8"> responseText.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">; i</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> char</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> responseText[i];</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (char </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '{'</span><span style="color:#E1E4E8">) braceCount</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">  else</span><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (char </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '}'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">    braceCount</span><span style="color:#F97583">--</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (braceCount </span><span style="color:#F97583">===</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">      argsEndIndex </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">      foundClosingBrace </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> true</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">      break</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The proxy also validates tool calls against the list of available tools. If the model invents a tool that doesn't exist, it gets filtered out. If all tool calls are invalid, the response is treated as regular text.</p>
<h2>Tool Call Streaming</h2>
<p>OpenCode expects tool calls to arrive as SSE chunks, same as regular text. <code>streamToolCalls()</code> in <code>streaming.js</code> sends an init chunk with the tool name and ID, then an args chunk with the arguments, then a final chunk with <code>finish_reason: 'tool_calls'</code>. Each chunk has a small delay (20ms, 10ms, 20ms) to feel like actual streaming.</p>
<h2>Conversation Summarization</h2>
<p>This one sneaked up on me. Straico has model context limits. Some models have 8k tokens, some have 128k. OpenCode sends the entire conversation history with every request. In a long coding session, that history grows fast.</p>
<p><code>summarizer.js</code> checks if the estimated token count is approaching the model's limit. When it hits a configurable threshold (default 70% of the model's <code>word_limit</code>), it takes all but the most recent messages, sends them to Straico for summarization, and replaces them with a single summary message.</p>
<p>The summarization itself uses Straico's <code>smart_llm_selector</code> with <code>pricing_method: balance</code>, so it picks a cheap model for the summary. Configurable via <code>SUMMARIZATION_MODEL</code>.</p>
<p>I'm still not 100% sure this is the right approach. The summary is lossy. Sometimes the model needs context from earlier messages that the summary glossed over. But without it, long sessions just fail with context limit errors. Tradeoff.</p>
<h2>Model Limits and Validation</h2>
<p><code>utils/model-limits.js</code> fetches all available models from Straico's <code>/models</code> endpoint at startup. It caches their context limits (<code>word_limit</code>) and max output tokens (<code>max_output</code>). The proxy uses this to validate incoming requests. If <code>estimated_input_tokens + max_tokens > word_limit</code>, it rejects the request with a 400 error before even hitting Straico.</p>
<p>The model list is also exposed at <code>/v1/models</code> so OpenCode can discover what's available. There's an admin endpoint at <code>/v1/admin/refresh-models</code> to force a refresh if Straico adds new models.</p>
<p>The sync script (<code>scripts/sync-opencode-config.js</code>) goes one step further. It fetches the model list from Straico, then updates <code>~/.config/opencode/opencode.json</code> with all chat-type models. The Docker entrypoint runs this script before starting the server, so the model list is always current.</p>
<h2>Authentication</h2>
<p>Four modes, controlled by <code>AUTH_MODE</code>:</p>
<ul>
<li><code>required</code> - Needs <code>PROXY_API_KEY</code>, rejects requests without it. Default in production.</li>
<li><code>optional</code> - Uses the key if set, warns if not. Default in development.</li>
<li><code>disabled</code> - No auth. For isolated environments.</li>
<li><code>external</code> - Trusts an external auth header. For when the proxy sits behind an API gateway or service mesh.</li>
</ul>
<p>The key comparison uses <code>crypto.timingSafeEqual</code> to prevent timing attacks. Took me a moment to realise I needed buffer length checks too, since <code>timingSafeEqual</code> throws if the buffers are different lengths.</p>
<h2>Retry and Graceful Shutdown</h2>
<p><code>BaseProvider.makeRequestWithRetry()</code> wraps every API call with exponential backoff. Retries on 429, 5xx, and network errors (<code>ECONNREFUSED</code>, <code>ECONNRESET</code>, <code>ETIMEDOUT</code>). Default is 3 attempts with a 1-second base delay.</p>
<p>Graceful shutdown was one of those things I didn't think about until I ran into issues. When Docker sends SIGTERM, the proxy stops accepting new requests and waits for active ones to drain. There's a timeout (default 30 seconds) after which it force-exits. Without this, long-running streaming responses would get cut off mid-chunk when the container restarted.</p>
<h2>Docker Setup</h2>
<p>The Dockerfile uses <code>node:18-alpine</code> and an entrypoint script. The entrypoint runs the OpenCode config sync, then starts the server.</p>
<p>Docker Compose mounts two volumes. The <code>.env</code> file for config. And <code>~/.config/opencode</code> so the sync script can write to the OpenCode config file.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">  - </span><span style="color:#9ECBFF">./.env:/app/.env:ro</span></span>
<span class="line"><span style="color:#E1E4E8">  - </span><span style="color:#9ECBFF">~/.config/opencode:/root/.config/opencode</span></span></code></pre>
<p>One thing I got wrong initially was the Dockerfile <code>CMD</code>. I had <code>CMD ["node", "server.js"]</code> which meant the config sync never ran. Switched to <code>ENTRYPOINT ["/app/docker-entrypoint.sh"]</code> and that fixed it. Small thing, but it meant every container restart would have stale model lists.</p>
<h2>The Straico-Specific Quirks</h2>
<p>Straico's API is mostly OpenAI-compatible but with some differences that caught me out.</p>
<p>Tool result messages use <code>role: "tool"</code> in OpenAI format. Straico doesn't support that role. The proxy converts them to <code>role: "user"</code> with a <code>[Tool Result]:</code> prefix. Same with assistant messages that contain tool calls. Those get converted to the text format the injection prompt expects.</p>
<p>Empty assistant messages get filtered out entirely. Some models return an assistant message with empty content before making a tool call. Straico chokes on those.</p>
<p>There's a <code>TOOL_RESULT_MAX_LENGTH</code> env var that truncates large tool outputs. Some tool results (file reads, command output) can be massive. Without truncation, they blow out the context window and the next request fails.</p>
<p>The proxy also normalises messages. OpenAI sends content as arrays of objects (text parts, image parts, system reminders). The proxy flattens those into plain strings and strips out <code>&#x3C;system-reminder></code> tags. Straico doesn't know what to do with the array format.</p>
<h2>What I'd Do Differently</h2>
<p>The provider pattern is solid but I'd start with it from the beginning rather than refactoring into it. The four-file structure worked fine until I wanted to add features that crossed module boundaries. The abstraction would have saved me some reshuffling.</p>
<p>The smart streaming mode is neat but I'd think harder about whether it's worth the complexity. The boundary detection handles most markdown but not all edge cases. <code>none</code> mode is faster and more reliable. I use <code>none</code> day to day.</p>
<p>The summarization feature is the part I'm least confident about. It works, but the lossy compression means sometimes context gets dropped at exactly the wrong moment. I might revisit this with a sliding window approach instead of a hard summarize-and-replace.</p>
<h2>Where It Stands</h2>
<p>The proxy handles:</p>
<ul>
<li>All 90+ Straico models through a single endpoint</li>
<li>Streaming simulation (both modes)</li>
<li>Function calling with four parser strategies</li>
<li>Conversation summarization for long sessions</li>
<li>Model context validation</li>
<li>Authentication with four modes</li>
<li>Retry with exponential backoff</li>
<li>Graceful shutdown with request draining</li>
<li>Docker deployment with automatic model sync</li>
</ul>
<p>It runs on my machine and OpenCode talks to it at <code>http://localhost:8000/v1</code>. Works well enough that I don't think about it most of the time. Which is exactly what a proxy should do.</p>
<p>The code is on GitHub if you want to look or use it. Or add a provider. The architecture supports it.</p>]]></description>
            <content:encoded><![CDATA[<p>I use <a href="/blog/opencode-nested-slash-commands-architecture">OpenCode</a> as my main AI coding tool. I switched from Claude Code after <a href="/blog/anthropic-open-source-walled-garden-clawdbot-opencode">Anthropic started going after open source projects</a> and I kept hitting session limits on my subscription.</p>
<p>OpenCode works with any OpenAI-compatible API. Straico gives me access to Claude, GPT, Gemini, DeepSeek, and a bunch more through a single API key. Cheap too. Problem is, Straico's API is missing two things OpenCode needs: streaming responses and function calling.</p>
<p>Without streaming, OpenCode just hangs. Never gets a response. But Straico keeps eating tokens on their end anyway. Without function calling, the AI can't use tools like reading files or running bash commands. Both are non-negotiable for an agentic coding tool.</p>
<p>So I built a proxy. It sits between OpenCode and Straico, translating requests and responses to fill in the gaps.</p>
<pre><code>OpenCode
  → localhost:8000 (my proxy)
    → Straico API
</code></pre>
<p>What started as "just simulate streaming and inject tool definitions" turned into a surprisingly full-featured thing. The codebase is at <a href="https://github.com/ManningWorks/DOAI-Proxy">github.com/ManningWorks/DOAI-Proxy</a>.</p>
<h2>The Architecture I Ended Up With</h2>
<p>I didn't start with a provider pattern. I started with four files: <code>server.js</code>, <code>streaming.js</code>, <code>tools.js</code>, <code>utils.js</code>. But once I started thinking about adding other providers down the line (OpenAI direct, Anthropic direct), I refactored into something cleaner.</p>
<p>The provider pattern lives in <code>providers/</code>. <code>BaseProvider</code> is an abstract class that handles the interface contract and retry logic. <code>StraicoProvider</code> extends it with Straico-specific request/response transformation. <code>ProviderFactory</code> instantiates the right one based on the <code>PROVIDER_TYPE</code> env var.</p>
<p>Right now only Straico exists, but the factory already has stubs for OpenAI and Anthropic. The <code>ADDING_PROVIDERS.md</code> doc in the repo lays out how to add a new one.</p>
<p>The other modules:</p>
<ul>
<li><code>server.js</code> - Express server, routing, auth, request lifecycle</li>
<li><code>streaming.js</code> - SSE simulation with two modes</li>
<li><code>tools.js</code> - Tool injection and response parsing</li>
<li><code>utils.js</code> - Logging, formatting, log rotation</li>
<li><code>utils/model-limits.js</code> - Fetches context limits from Straico's API</li>
<li><code>summarizer.js</code> - Conversation summarization for long sessions</li>
<li><code>scripts/sync-opencode-config.js</code> - Syncs model list to OpenCode config</li>
</ul>
<h2>Streaming Without Streaming</h2>
<p>Straico returns the full response at once. No SSE. No chunks. The proxy has to fake it.</p>
<p>Two modes: <code>none</code> and <code>smart</code>.</p>
<p><code>none</code> is what I'd recommend as default. It sends the entire response in one SSE chunk, then the <code>[DONE]</code> marker. Fast, no formatting issues, still technically SSE.</p>
<p><code>smart</code> is more interesting. It splits the response into chunks with delays to simulate real streaming. The naive approach is <code>responseText.match(new RegExp('.{1,15}', 'g'))</code> and that kind of works. But it breaks markdown. Split mid-bold, mid-code-block, mid-backtick and the rendering glitches.</p>
<p>So <code>smartChunkText()</code> in <code>streaming.js</code> looks for safe boundaries. It prefers splitting on newlines, then whitespace. It also checks for markdown delimiters (<code>**</code>, <code>__</code>, <code>\</code>``<code>, `` </code> ``) and extends the chunk to avoid splitting them. There's a max size limit (<code>targetSize * 10</code>) to prevent infinite extension.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// streaming.js - simplified version of the boundary logic</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">let</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> end; i </span><span style="color:#F97583">></span><span style="color:#E1E4E8"> start; i</span><span style="color:#F97583">--</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (text[i] </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '</span><span style="color:#79B8FF">\n</span><span style="color:#9ECBFF">'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">    return</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> delim</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> [</span><span style="color:#9ECBFF">'**'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'__'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'```'</span><span style="color:#E1E4E8">, </span><span style="color:#9ECBFF">'`'</span><span style="color:#E1E4E8">]) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> delimStart</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> text.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(delim, start);</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (delimStart </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> delimStart </span><span style="color:#F97583">&#x3C;</span><span style="color:#E1E4E8"> end) {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> delimEnd</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> delimStart </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> delim.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (delimEnd </span><span style="color:#F97583">></span><span style="color:#E1E4E8"> end) {</span></span>
<span class="line"><span style="color:#F97583">      return</span><span style="color:#E1E4E8"> Math.</span><span style="color:#B392F0">min</span><span style="color:#E1E4E8">(delimEnd, text.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">, start </span><span style="color:#F97583">+</span><span style="color:#E1E4E8"> maxSize);</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Default is 15 characters per chunk with 80ms delay. That feels about right for most models. Configurable via <code>STREAM_CHUNK_SIZE</code> and <code>STREAM_DELAY_MS</code> env vars.</p>
<p>I set <code>STREAM_MODE=none</code> as the recommended default. <code>smart</code> works but it's more of a showcase thing. The boundary detection catches most cases but I wouldn't trust it with complex nested markdown.</p>
<h2>Function Calling via Prompt Injection</h2>
<p>Straico doesn't support function calling natively. The workaround: inject tool definitions into the system prompt and parse the AI's response to detect tool calls.</p>
<p><code>injectToolsIntoSystem()</code> in <code>tools.js</code> appends a formatted list of available tools to the system message:</p>
<pre><code>You have access to the following tools:
- bash: Run bash commands
- read: Read file contents

When you need to use a tool, format your response like this:
TOOL_CALL: &#x3C;tool_name>
ARGUMENTS: &#x3C;json_arguments>
</code></pre>
<p>There's a sentinel comment (<code>&#x3C;!-- proxy-tools-injected --></code>) to prevent double-injection if the same messages get processed twice.</p>
<p>The tricky part is parsing. Different models output tool calls in different formats. I ended up with four parsers that run in sequence:</p>
<ol>
<li><strong>Minimax XML</strong> - <code>&#x3C;minimax:tool_call></code> with <code>&#x3C;invoke></code> tags</li>
<li><strong>Claude XML</strong> - <code>&#x3C;invoke name="..."></code> with <code>&#x3C;parameter_list></code> tags</li>
<li><strong>OpenAI Native</strong> - JSON with <code>"tool_calls": [...]</code> embedded in the response</li>
<li><strong>Text Format</strong> - The <code>TOOL_CALL: / ARGUMENTS:</code> format from the injection prompt</li>
</ol>
<p>Each parser tries to extract tool calls from the response text. The first one that succeeds wins. This was a gradual thing. I started with just the text format parser. Then Minimax models returned XML. Then Claude models returned different XML. Then some models returned JSON that looked like OpenAI's format. Four parsers later and it handles most cases.</p>
<p>The text format parser was the hardest to get right. Matching <code>TOOL_CALL: tool_name ARGUMENTS: {json}</code> seems simple until the JSON contains nested objects, strings with braces, or the model forgets the space between the tool name and ARGUMENTS. The implementation tracks brace depth to find where the JSON actually ends:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">let</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> argsStartIndex; i </span><span style="color:#F97583">&#x3C;</span><span style="color:#E1E4E8"> responseText.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">; i</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> char</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> responseText[i];</span></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (char </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '{'</span><span style="color:#E1E4E8">) braceCount</span><span style="color:#F97583">++</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">  else</span><span style="color:#F97583"> if</span><span style="color:#E1E4E8"> (char </span><span style="color:#F97583">===</span><span style="color:#9ECBFF"> '}'</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">    braceCount</span><span style="color:#F97583">--</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">    if</span><span style="color:#E1E4E8"> (braceCount </span><span style="color:#F97583">===</span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">) {</span></span>
<span class="line"><span style="color:#E1E4E8">      argsEndIndex </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">      foundClosingBrace </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> true</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#F97583">      break</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The proxy also validates tool calls against the list of available tools. If the model invents a tool that doesn't exist, it gets filtered out. If all tool calls are invalid, the response is treated as regular text.</p>
<h2>Tool Call Streaming</h2>
<p>OpenCode expects tool calls to arrive as SSE chunks, same as regular text. <code>streamToolCalls()</code> in <code>streaming.js</code> sends an init chunk with the tool name and ID, then an args chunk with the arguments, then a final chunk with <code>finish_reason: 'tool_calls'</code>. Each chunk has a small delay (20ms, 10ms, 20ms) to feel like actual streaming.</p>
<h2>Conversation Summarization</h2>
<p>This one sneaked up on me. Straico has model context limits. Some models have 8k tokens, some have 128k. OpenCode sends the entire conversation history with every request. In a long coding session, that history grows fast.</p>
<p><code>summarizer.js</code> checks if the estimated token count is approaching the model's limit. When it hits a configurable threshold (default 70% of the model's <code>word_limit</code>), it takes all but the most recent messages, sends them to Straico for summarization, and replaces them with a single summary message.</p>
<p>The summarization itself uses Straico's <code>smart_llm_selector</code> with <code>pricing_method: balance</code>, so it picks a cheap model for the summary. Configurable via <code>SUMMARIZATION_MODEL</code>.</p>
<p>I'm still not 100% sure this is the right approach. The summary is lossy. Sometimes the model needs context from earlier messages that the summary glossed over. But without it, long sessions just fail with context limit errors. Tradeoff.</p>
<h2>Model Limits and Validation</h2>
<p><code>utils/model-limits.js</code> fetches all available models from Straico's <code>/models</code> endpoint at startup. It caches their context limits (<code>word_limit</code>) and max output tokens (<code>max_output</code>). The proxy uses this to validate incoming requests. If <code>estimated_input_tokens + max_tokens > word_limit</code>, it rejects the request with a 400 error before even hitting Straico.</p>
<p>The model list is also exposed at <code>/v1/models</code> so OpenCode can discover what's available. There's an admin endpoint at <code>/v1/admin/refresh-models</code> to force a refresh if Straico adds new models.</p>
<p>The sync script (<code>scripts/sync-opencode-config.js</code>) goes one step further. It fetches the model list from Straico, then updates <code>~/.config/opencode/opencode.json</code> with all chat-type models. The Docker entrypoint runs this script before starting the server, so the model list is always current.</p>
<h2>Authentication</h2>
<p>Four modes, controlled by <code>AUTH_MODE</code>:</p>
<ul>
<li><code>required</code> - Needs <code>PROXY_API_KEY</code>, rejects requests without it. Default in production.</li>
<li><code>optional</code> - Uses the key if set, warns if not. Default in development.</li>
<li><code>disabled</code> - No auth. For isolated environments.</li>
<li><code>external</code> - Trusts an external auth header. For when the proxy sits behind an API gateway or service mesh.</li>
</ul>
<p>The key comparison uses <code>crypto.timingSafeEqual</code> to prevent timing attacks. Took me a moment to realise I needed buffer length checks too, since <code>timingSafeEqual</code> throws if the buffers are different lengths.</p>
<h2>Retry and Graceful Shutdown</h2>
<p><code>BaseProvider.makeRequestWithRetry()</code> wraps every API call with exponential backoff. Retries on 429, 5xx, and network errors (<code>ECONNREFUSED</code>, <code>ECONNRESET</code>, <code>ETIMEDOUT</code>). Default is 3 attempts with a 1-second base delay.</p>
<p>Graceful shutdown was one of those things I didn't think about until I ran into issues. When Docker sends SIGTERM, the proxy stops accepting new requests and waits for active ones to drain. There's a timeout (default 30 seconds) after which it force-exits. Without this, long-running streaming responses would get cut off mid-chunk when the container restarted.</p>
<h2>Docker Setup</h2>
<p>The Dockerfile uses <code>node:18-alpine</code> and an entrypoint script. The entrypoint runs the OpenCode config sync, then starts the server.</p>
<p>Docker Compose mounts two volumes. The <code>.env</code> file for config. And <code>~/.config/opencode</code> so the sync script can write to the OpenCode config file.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">volumes</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#E1E4E8">  - </span><span style="color:#9ECBFF">./.env:/app/.env:ro</span></span>
<span class="line"><span style="color:#E1E4E8">  - </span><span style="color:#9ECBFF">~/.config/opencode:/root/.config/opencode</span></span></code></pre>
<p>One thing I got wrong initially was the Dockerfile <code>CMD</code>. I had <code>CMD ["node", "server.js"]</code> which meant the config sync never ran. Switched to <code>ENTRYPOINT ["/app/docker-entrypoint.sh"]</code> and that fixed it. Small thing, but it meant every container restart would have stale model lists.</p>
<h2>The Straico-Specific Quirks</h2>
<p>Straico's API is mostly OpenAI-compatible but with some differences that caught me out.</p>
<p>Tool result messages use <code>role: "tool"</code> in OpenAI format. Straico doesn't support that role. The proxy converts them to <code>role: "user"</code> with a <code>[Tool Result]:</code> prefix. Same with assistant messages that contain tool calls. Those get converted to the text format the injection prompt expects.</p>
<p>Empty assistant messages get filtered out entirely. Some models return an assistant message with empty content before making a tool call. Straico chokes on those.</p>
<p>There's a <code>TOOL_RESULT_MAX_LENGTH</code> env var that truncates large tool outputs. Some tool results (file reads, command output) can be massive. Without truncation, they blow out the context window and the next request fails.</p>
<p>The proxy also normalises messages. OpenAI sends content as arrays of objects (text parts, image parts, system reminders). The proxy flattens those into plain strings and strips out <code>&#x3C;system-reminder></code> tags. Straico doesn't know what to do with the array format.</p>
<h2>What I'd Do Differently</h2>
<p>The provider pattern is solid but I'd start with it from the beginning rather than refactoring into it. The four-file structure worked fine until I wanted to add features that crossed module boundaries. The abstraction would have saved me some reshuffling.</p>
<p>The smart streaming mode is neat but I'd think harder about whether it's worth the complexity. The boundary detection handles most markdown but not all edge cases. <code>none</code> mode is faster and more reliable. I use <code>none</code> day to day.</p>
<p>The summarization feature is the part I'm least confident about. It works, but the lossy compression means sometimes context gets dropped at exactly the wrong moment. I might revisit this with a sliding window approach instead of a hard summarize-and-replace.</p>
<h2>Where It Stands</h2>
<p>The proxy handles:</p>
<ul>
<li>All 90+ Straico models through a single endpoint</li>
<li>Streaming simulation (both modes)</li>
<li>Function calling with four parser strategies</li>
<li>Conversation summarization for long sessions</li>
<li>Model context validation</li>
<li>Authentication with four modes</li>
<li>Retry with exponential backoff</li>
<li>Graceful shutdown with request draining</li>
<li>Docker deployment with automatic model sync</li>
</ul>
<p>It runs on my machine and OpenCode talks to it at <code>http://localhost:8000/v1</code>. Works well enough that I don't think about it most of the time. Which is exactly what a proxy should do.</p>
<p>The code is on GitHub if you want to look or use it. Or add a provider. The architecture supports it.</p>]]></content:encoded>
            <category>opencode</category>
        </item>
        <item>
            <title><![CDATA[My Subagents Kept Asking Permission for Everything: The Config Ordering Trap]]></title>
            <link>https://lukemanning.ie/blog/opencode-subagent-permissions-ordering-trap</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/opencode-subagent-permissions-ordering-trap</guid>
            <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>Every time my release-manager subagent tried to run <code>grep</code> or <code>git log</code>, OpenCode prompted me for approval. I had explicit <code>"grep *": allow</code> rules in the config. They weren't working.</p>
<p>This tripped me up for way longer than it should have. (I'm running OpenCode v1.4 — permission behavior may differ in other versions.)</p>
<h2>My Config</h2>
<p>The <a href="https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/release-manager.md">release-manager agent</a> handles version bumps, changelogs, and git tagging for my Projex project. Its config in <code>.opencode/agents/release-manager.md</code> had bash permissions like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">  "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span></code></pre>
<p>Looks reasonable. Specific commands are allowed, everything else asks. Except every single command was prompting. <code>grep</code>, <code>git log</code>, <code>cat</code> — all asking for permission.</p>
<h2>The Problem</h2>
<p>I re-read the config about five times. The rules were right there — <code>"grep *": allow</code> — clear as day. I tried shuffling the order around. Tried different wildcard syntax. Nothing worked.</p>
<p>Eventually I went back to the <a href="https://opencode.ai/docs/permissions/">OpenCode permissions docs</a> and actually looked at the examples. Every single one puts the catch-all <code>"*": "ask"</code> at the top. Mine was at the bottom.</p>
<p>Turns out OpenCode permission rules work on a "last matching rule wins" principle.</p>
<p>The <code>"*"</code> wildcard matches everything. Including <code>grep</code>. Including <code>git log</code>. So when OpenCode evaluates <code>git log --oneline -20</code> against my config, both <code>"git log -- *"</code> and <code>"*"</code> match. Since <code>"*"</code> is listed last, it wins. Result: <code>ask</code>.</p>
<p>Every. Single. Time.</p>
<p>The fix was embarrassingly simple. Move the catch-all to the top:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">  "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">  "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>Now <code>"*"</code> matches first, but then <code>"grep *"</code> matches after and wins because it's the last matching rule. Specific overrides general. The way it should work.</p>
<p>I'd put the catch-all at the bottom out of habit — like a default case in a switch statement. I just... didn't read carefully enough.</p>
<h2>The Second Problem</h2>
<p>After fixing the ordering, <code>grep</code> worked fine. But <code>git log --oneline -20</code> <em>still</em> prompted.</p>
<p>I tested a few more variations to narrow it down. <code>git log -- somefile</code> (with the path separator) matched. <code>git log --oneline</code> didn't. The difference clicked pretty fast after that.</p>
<p>The pattern <code>"git log -- *"</code> only matches commands that literally have <code>-- </code> in them. Like <code>git log -- somefile</code>. It doesn't match <code>git log --oneline -20</code> because <code>--oneline</code> is a flag, not the <code>--</code> path separator.</p>
<p>I'd been too specific. Changed to:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#9ECBFF">  "git log *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>Both forms — with and without arguments. The <code>*</code> wildcard matches zero or more of any character. So <code>"git log *"</code> covers <code>git log --oneline -20</code>. But the space before <code>*</code> is literal. The pattern requires "git log" followed by a space, then anything. A bare <code>git log</code> with no trailing space won't match it. That's why <code>"git log"</code> (no wildcard) is also needed.</p>
<h2>The Third Problem</h2>
<p>Even after both fixes, it <em>still</em> prompted. I'd been eyeing the <code>tools</code> field suspiciously since the start. The config had both the deprecated <code>tools</code> block and the newer <code>permission</code> block.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">tools</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  read</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  write</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  edit</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">permission</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>The docs say <code>tools.bash: true</code> is equivalent to <code>{"*": "allow"}</code>. Having both <code>tools</code> and <code>permission</code> for the same thing seemed like it could cause conflicts — one saying "allow everything" and the other saying "ask for everything except these." I don't know exactly how OpenCode resolves this internally, but removing the <code>tools</code> block entirely fixed the remaining issues.</p>
<h2>The Working Config</h2>
<p>The final config for the release-manager agent looks like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">description</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Create releases following Projex's version bump, changelog, and git tagging workflow</span></span>
<span class="line"><span style="color:#85E89D">mode</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">subagent</span></span>
<span class="line"><span style="color:#85E89D">temperature</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0.1</span></span>
<span class="line"><span style="color:#85E89D">permission</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  edit</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/core/package.json"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "CHANGELOG.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "README.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/core/README.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/docs/**/*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * build"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * lint"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * typecheck"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * test"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "head *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "ls *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "ls -la *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "find *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "tail *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "wc *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "wc -l *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "sort *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "uniq *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git log *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git log"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git diff *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git diff"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git status *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git tag *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#85E89D">  webfetch</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">deny</span></span>
<span class="line"><span style="color:#85E89D">  color</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">success</span></span>
<span class="line"><span style="color:#B392F0">---</span></span></code></pre>
<p>Three separate issues stacked on top of each other. Rule ordering, overly narrow patterns, and deprecated config conflicting with new config.</p>
<h2>TL;DR</h2>
<p>Three things I learned about OpenCode subagent permissions:</p>
<ol>
<li><strong>Catch-all goes first.</strong> <code>"*": ask</code> needs to be at the top of the permission block. Last matching rule wins.</li>
<li><strong>Patterns need to match actual command syntax.</strong> <code>"git log -- *"</code> doesn't match <code>git log --oneline</code>. I had to use <code>"git log *"</code> instead.</li>
<li><strong>Don't mix <code>tools</code> and <code>permission</code>.</strong> Removing the deprecated <code>tools</code> field fixed the remaining issues for me.</li>
</ol>
<p>I fixed the same issues in my <a href="https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/documentation-manager.md">documentation-manager agent</a> too. Both agents now run without prompting on every command. Which is how it should have been from the start.</p>]]></description>
            <content:encoded><![CDATA[<p>Every time my release-manager subagent tried to run <code>grep</code> or <code>git log</code>, OpenCode prompted me for approval. I had explicit <code>"grep *": allow</code> rules in the config. They weren't working.</p>
<p>This tripped me up for way longer than it should have. (I'm running OpenCode v1.4 — permission behavior may differ in other versions.)</p>
<h2>My Config</h2>
<p>The <a href="https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/release-manager.md">release-manager agent</a> handles version bumps, changelogs, and git tagging for my Projex project. Its config in <code>.opencode/agents/release-manager.md</code> had bash permissions like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">  "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span></code></pre>
<p>Looks reasonable. Specific commands are allowed, everything else asks. Except every single command was prompting. <code>grep</code>, <code>git log</code>, <code>cat</code> — all asking for permission.</p>
<h2>The Problem</h2>
<p>I re-read the config about five times. The rules were right there — <code>"grep *": allow</code> — clear as day. I tried shuffling the order around. Tried different wildcard syntax. Nothing worked.</p>
<p>Eventually I went back to the <a href="https://opencode.ai/docs/permissions/">OpenCode permissions docs</a> and actually looked at the examples. Every single one puts the catch-all <code>"*": "ask"</code> at the top. Mine was at the bottom.</p>
<p>Turns out OpenCode permission rules work on a "last matching rule wins" principle.</p>
<p>The <code>"*"</code> wildcard matches everything. Including <code>grep</code>. Including <code>git log</code>. So when OpenCode evaluates <code>git log --oneline -20</code> against my config, both <code>"git log -- *"</code> and <code>"*"</code> match. Since <code>"*"</code> is listed last, it wins. Result: <code>ask</code>.</p>
<p>Every. Single. Time.</p>
<p>The fix was embarrassingly simple. Move the catch-all to the top:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">  "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">  "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff -- *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>Now <code>"*"</code> matches first, but then <code>"grep *"</code> matches after and wins because it's the last matching rule. Specific overrides general. The way it should work.</p>
<p>I'd put the catch-all at the bottom out of habit — like a default case in a switch statement. I just... didn't read carefully enough.</p>
<h2>The Second Problem</h2>
<p>After fixing the ordering, <code>grep</code> worked fine. But <code>git log --oneline -20</code> <em>still</em> prompted.</p>
<p>I tested a few more variations to narrow it down. <code>git log -- somefile</code> (with the path separator) matched. <code>git log --oneline</code> didn't. The difference clicked pretty fast after that.</p>
<p>The pattern <code>"git log -- *"</code> only matches commands that literally have <code>-- </code> in them. Like <code>git log -- somefile</code>. It doesn't match <code>git log --oneline -20</code> because <code>--oneline</code> is a flag, not the <code>--</code> path separator.</p>
<p>I'd been too specific. Changed to:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#9ECBFF">  "git log *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git log"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git diff"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">  "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>Both forms — with and without arguments. The <code>*</code> wildcard matches zero or more of any character. So <code>"git log *"</code> covers <code>git log --oneline -20</code>. But the space before <code>*</code> is literal. The pattern requires "git log" followed by a space, then anything. A bare <code>git log</code> with no trailing space won't match it. That's why <code>"git log"</code> (no wildcard) is also needed.</p>
<h2>The Third Problem</h2>
<p>Even after both fixes, it <em>still</em> prompted. I'd been eyeing the <code>tools</code> field suspiciously since the start. The config had both the deprecated <code>tools</code> block and the newer <code>permission</code> block.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">tools</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  read</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  write</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  edit</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#85E89D">permission</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span></code></pre>
<p>The docs say <code>tools.bash: true</code> is equivalent to <code>{"*": "allow"}</code>. Having both <code>tools</code> and <code>permission</code> for the same thing seemed like it could cause conflicts — one saying "allow everything" and the other saying "ask for everything except these." I don't know exactly how OpenCode resolves this internally, but removing the <code>tools</code> block entirely fixed the remaining issues.</p>
<h2>The Working Config</h2>
<p>The final config for the release-manager agent looks like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">description</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">Create releases following Projex's version bump, changelog, and git tagging workflow</span></span>
<span class="line"><span style="color:#85E89D">mode</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">subagent</span></span>
<span class="line"><span style="color:#85E89D">temperature</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">0.1</span></span>
<span class="line"><span style="color:#85E89D">permission</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#85E89D">  edit</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/core/package.json"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "CHANGELOG.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "README.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/core/README.md"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "packages/docs/**/*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#85E89D">  bash</span><span style="color:#E1E4E8">:</span></span>
<span class="line"><span style="color:#9ECBFF">    "*"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">ask</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * build"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * lint"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * typecheck"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "pnpm --filter * test"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "head *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "ls *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "ls -la *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "find *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "grep *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "rg *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "cat *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "tail *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "wc *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "wc -l *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "sort *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "uniq *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git log *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git log"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git diff *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git diff"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git status *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git status"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#9ECBFF">    "git tag *"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">allow</span></span>
<span class="line"><span style="color:#85E89D">  webfetch</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">deny</span></span>
<span class="line"><span style="color:#85E89D">  color</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">success</span></span>
<span class="line"><span style="color:#B392F0">---</span></span></code></pre>
<p>Three separate issues stacked on top of each other. Rule ordering, overly narrow patterns, and deprecated config conflicting with new config.</p>
<h2>TL;DR</h2>
<p>Three things I learned about OpenCode subagent permissions:</p>
<ol>
<li><strong>Catch-all goes first.</strong> <code>"*": ask</code> needs to be at the top of the permission block. Last matching rule wins.</li>
<li><strong>Patterns need to match actual command syntax.</strong> <code>"git log -- *"</code> doesn't match <code>git log --oneline</code>. I had to use <code>"git log *"</code> instead.</li>
<li><strong>Don't mix <code>tools</code> and <code>permission</code>.</strong> Removing the deprecated <code>tools</code> field fixed the remaining issues for me.</li>
</ol>
<p>I fixed the same issues in my <a href="https://github.com/ManningWorks/Projex/blob/main/.opencode/agents/documentation-manager.md">documentation-manager agent</a> too. Both agents now run without prompting on every command. Which is how it should have been from the start.</p>]]></content:encoded>
            <category>opencode</category>
        </item>
        <item>
            <title><![CDATA[Trying to use opencode inside tmux was a struggle]]></title>
            <link>https://lukemanning.ie/blog/opencode-tmux-getting-used-to-keybindings</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/opencode-tmux-getting-used-to-keybindings</guid>
            <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I usually run opencode in a simple WSL terminal window. Somtimes I have multiple instances across different tabs in my Terminal. I came across a video recently on Youtube talking about tmux. I never had a reason to avoid tmux — I just never set it up properly. After watchging the video I was convinced I needed to give it a go, so I decided to actually try running my workflow inside tmux. Turns out that wasn't so straightforward. A series of small issues made the initial attempts quite frustrating.</p>
<h2>The Escape Key Did Nothing</h2>
<p>First problem: I hit Esc to interrupt an agent mid-task. Nothing happened. Just sat there. I hit Esc again. Nothing. The agent kept running.</p>
<p>I'm not sure what I expected tmux to do with Escape, but apparently it intercepts the key for its own bindings and there's a delay before the key reaches the application inside. I wondered if opencode had a bug or just didn't work well with opencode.</p>
<p>Then I searched "tmux escape key not working" and found <code>set -g escape-time 10</code> which reduces that delay. Shorter would be faster but 10ms worked. After that, Escape actually reached opencode.</p>
<p>But that alone wasn't enough for clipboard to work.</p>
<h2>Copying Text Was Broken</h2>
<p>Selecting text in tmux with the mouse didn't copy to my system clipboard. I'd select, paste somewhere else, and get nothing. This is on WSL2 with Ubuntu on Windows — so I have WSLg, which is the Windows layer that lets Linux GUI apps run on Windows 11. But something was intercepting it.</p>
<p>I tried:</p>
<ul>
<li>Checking if WSLg was actually running — it was</li>
<li>Googled "tmux clipboard copy not working wsl2 in opencode" — that's how I found most of the solution</li>
</ul>
<p>What actually fixed it was adding these to my <code>~/.tmux.conf</code>:</p>
<pre><code>set -g escape-time 10
set -g mouse on
set -g set-clipboard on
set -g allow-passthrough on
</code></pre>
<p><code>set -g mouse on</code> enables mouse mode so I can select with the mouse at all. <code>set -g set-clipboard on</code> is the key one. It's been around since tmux 2.9 and it handles clipboard integration properly. <code>set -g allow-passthrough on</code> lets applications inside tmux access the system clipboard without tmux intercepting it first. That last one seems particularly relevant for WSL setups, though it might help on other platforms too.</p>
<p>I'm running tmux 3.4 — From what I've reda these settings should work on tmux 2.9+, which covers most reasonable install paths in 2026.</p>
<p>To reload after changing tmux.conf, run <code>tmux source-file ~/.tmux.conf</code>.</p>
<h2>Still Getting Used to It</h2>
<p>I'm still getting used to the keybindings and functionality inside tmux. I'm still building the muscle memory. The clipboard issue was the biggest blocker, now that works, so the rest is just practice.</p>
<p>The other opencode posts (<a href="/blog/opencode-subagent-permissions-ordering-trap">permissions trap</a>, <a href="/blog/opencode-nested-slash-commands-architecture">nested commands</a>) don't mention tmux because I wasn't using it. Now I am, and now I know why people talk about these specific settings. It's quite nice having so much flexibility inside a single terminal window.</p>
<p>I'm genuinely not sure how anyone uses tmux without these settings. Maybe they were just magic configuration I never had.</p>]]></description>
            <content:encoded><![CDATA[<p>I usually run opencode in a simple WSL terminal window. Somtimes I have multiple instances across different tabs in my Terminal. I came across a video recently on Youtube talking about tmux. I never had a reason to avoid tmux — I just never set it up properly. After watchging the video I was convinced I needed to give it a go, so I decided to actually try running my workflow inside tmux. Turns out that wasn't so straightforward. A series of small issues made the initial attempts quite frustrating.</p>
<h2>The Escape Key Did Nothing</h2>
<p>First problem: I hit Esc to interrupt an agent mid-task. Nothing happened. Just sat there. I hit Esc again. Nothing. The agent kept running.</p>
<p>I'm not sure what I expected tmux to do with Escape, but apparently it intercepts the key for its own bindings and there's a delay before the key reaches the application inside. I wondered if opencode had a bug or just didn't work well with opencode.</p>
<p>Then I searched "tmux escape key not working" and found <code>set -g escape-time 10</code> which reduces that delay. Shorter would be faster but 10ms worked. After that, Escape actually reached opencode.</p>
<p>But that alone wasn't enough for clipboard to work.</p>
<h2>Copying Text Was Broken</h2>
<p>Selecting text in tmux with the mouse didn't copy to my system clipboard. I'd select, paste somewhere else, and get nothing. This is on WSL2 with Ubuntu on Windows — so I have WSLg, which is the Windows layer that lets Linux GUI apps run on Windows 11. But something was intercepting it.</p>
<p>I tried:</p>
<ul>
<li>Checking if WSLg was actually running — it was</li>
<li>Googled "tmux clipboard copy not working wsl2 in opencode" — that's how I found most of the solution</li>
</ul>
<p>What actually fixed it was adding these to my <code>~/.tmux.conf</code>:</p>
<pre><code>set -g escape-time 10
set -g mouse on
set -g set-clipboard on
set -g allow-passthrough on
</code></pre>
<p><code>set -g mouse on</code> enables mouse mode so I can select with the mouse at all. <code>set -g set-clipboard on</code> is the key one. It's been around since tmux 2.9 and it handles clipboard integration properly. <code>set -g allow-passthrough on</code> lets applications inside tmux access the system clipboard without tmux intercepting it first. That last one seems particularly relevant for WSL setups, though it might help on other platforms too.</p>
<p>I'm running tmux 3.4 — From what I've reda these settings should work on tmux 2.9+, which covers most reasonable install paths in 2026.</p>
<p>To reload after changing tmux.conf, run <code>tmux source-file ~/.tmux.conf</code>.</p>
<h2>Still Getting Used to It</h2>
<p>I'm still getting used to the keybindings and functionality inside tmux. I'm still building the muscle memory. The clipboard issue was the biggest blocker, now that works, so the rest is just practice.</p>
<p>The other opencode posts (<a href="/blog/opencode-subagent-permissions-ordering-trap">permissions trap</a>, <a href="/blog/opencode-nested-slash-commands-architecture">nested commands</a>) don't mention tmux because I wasn't using it. Now I am, and now I know why people talk about these specific settings. It's quite nice having so much flexibility inside a single terminal window.</p>
<p>I'm genuinely not sure how anyone uses tmux without these settings. Maybe they were just magic configuration I never had.</p>]]></content:encoded>
            <category>opencode</category>
        </item>
        <item>
            <title><![CDATA[OpenCode Nested Commands: $1 Solution, -102 Lines]]></title>
            <link>https://lukemanning.ie/blog/opencode-nested-slash-commands-architecture</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/opencode-nested-slash-commands-architecture</guid>
            <pubDate>Tue, 13 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>Here's what tripped me up: I tried to make a slash command call another slash command.</p>
<p>I had two commands doing basically the same thing. One was my 4-agent blog review system—495 lines of voice validation, completeness checking, structure editing, and technical validation (I <a href="/blog/building-multi-agent-blog-review-system">wrote about the full 4-agent system</a>). The other was a wrapper that auto-selects the next post needing review, then calls the first command. 239 lines of tracking and selection logic.</p>
<h2>What I Tried (and Why It Failed)</h2>
<p>The wrapper command was supposed to be simple: <code>/review-next-blog-post</code>. This wrapper then called the custom slash command <code>/review-blog-post-multi-agent @content/posts/specific-post</code> on the "next" post to be reviewed in my list of posts, based on criteria I defined.</p>
<p>I tried different variations. Maybe the slash command syntax was wrong? Maybe I needed quotes or a different calling pattern? Still nothing. I spent way too long wondering why OpenCode was just... ignoring my nested command call.</p>
<p>Turns out OpenCode slash commands can't call other slash commands. Each command is like a separate executable. You can't have <code>command-a</code> trigger <code>command-b</code> from within command-a's definition.</p>
<h2>The Realization</h2>
<p>This actually makes sense—predictable behavior, no infinite loops, no command calling itself. But I didn't know that going in, and the lack of error feedback made the confusion worse.</p>
<p>I thought about a few options:</p>
<ul>
<li>Could I work around this somehow with some intermediate command?</li>
<li>Maybe use the CLI tool to trigger commands?</li>
<li>Wait—what if I just made one command do both?</li>
</ul>
<p>The last option felt right. Instead of trying to make commands nested, I needed a single command that could operate in two different modes.</p>
<h2>The Solution: One Command, Two Modes OpenCode's <code>$1</code> argument system handles this perfectly:</h2>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8;font-weight:bold">**Auto-Selection Mode (no arguments):**</span></span>
<span class="line"><span style="color:#79B8FF">`/review-blog-post-multi-agent`</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8;font-weight:bold">**Direct Review Mode (specific post):**</span></span>
<span class="line"><span style="color:#79B8FF">`/review-blog-post-multi-agent @content/posts/blog-post-slug.md`</span><span style="color:#E1E4E8"> or </span><span style="color:#79B8FF">`path/to/blog-post.md`</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8;font-weight:bold">**Mode Selection Logic:**</span></span>
<span class="line"><span style="color:#FFAB70">-</span><span style="color:#E1E4E8"> If </span><span style="color:#79B8FF">`$1`</span><span style="color:#E1E4E8"> is provided AND not empty → Direct Review Mode</span></span>
<span class="line"><span style="color:#FFAB70">-</span><span style="color:#E1E4E8"> If </span><span style="color:#79B8FF">`$1`</span><span style="color:#E1E4E8"> is empty or missing → Auto-Selection Mode</span></span></code></pre>
<p>If <code>$1</code> is provided, use the file directly. If it's missing, run auto-selection. The command now does both modes in one place—finds the next post OR reviews the one you specify.</p>
<h2>The Result</h2>
<p>The consolidation was a net improvement: from 2 commands totaling 734 lines to 1 command with 632 lines. That's -102 lines while adding more functionality.</p>
<p>Here's what changed:</p>
<ul>
<li><strong>Tracking logic</strong>: Moved from the wrapper command into <code>project_docs/blog-review-tracking.json</code> as reusable content</li>
<li><strong>Auto-selection logic</strong>: Now part of the main command instead of a separate wrapper</li>
<li><strong>Architecture</strong>: No nested dependencies—just one command that can operate in two modes</li>
<li><strong>Usage</strong>: I can run <code>/review-blog-post-multi-agent</code> for auto-selection OR <code>/review-blog-post-multi-agent @content/posts/specific-post.md</code> for direct review</li>
</ul>]]></description>
            <content:encoded><![CDATA[<p>Here's what tripped me up: I tried to make a slash command call another slash command.</p>
<p>I had two commands doing basically the same thing. One was my 4-agent blog review system—495 lines of voice validation, completeness checking, structure editing, and technical validation (I <a href="/blog/building-multi-agent-blog-review-system">wrote about the full 4-agent system</a>). The other was a wrapper that auto-selects the next post needing review, then calls the first command. 239 lines of tracking and selection logic.</p>
<h2>What I Tried (and Why It Failed)</h2>
<p>The wrapper command was supposed to be simple: <code>/review-next-blog-post</code>. This wrapper then called the custom slash command <code>/review-blog-post-multi-agent @content/posts/specific-post</code> on the "next" post to be reviewed in my list of posts, based on criteria I defined.</p>
<p>I tried different variations. Maybe the slash command syntax was wrong? Maybe I needed quotes or a different calling pattern? Still nothing. I spent way too long wondering why OpenCode was just... ignoring my nested command call.</p>
<p>Turns out OpenCode slash commands can't call other slash commands. Each command is like a separate executable. You can't have <code>command-a</code> trigger <code>command-b</code> from within command-a's definition.</p>
<h2>The Realization</h2>
<p>This actually makes sense—predictable behavior, no infinite loops, no command calling itself. But I didn't know that going in, and the lack of error feedback made the confusion worse.</p>
<p>I thought about a few options:</p>
<ul>
<li>Could I work around this somehow with some intermediate command?</li>
<li>Maybe use the CLI tool to trigger commands?</li>
<li>Wait—what if I just made one command do both?</li>
</ul>
<p>The last option felt right. Instead of trying to make commands nested, I needed a single command that could operate in two different modes.</p>
<h2>The Solution: One Command, Two Modes OpenCode's <code>$1</code> argument system handles this perfectly:</h2>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8;font-weight:bold">**Auto-Selection Mode (no arguments):**</span></span>
<span class="line"><span style="color:#79B8FF">`/review-blog-post-multi-agent`</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8;font-weight:bold">**Direct Review Mode (specific post):**</span></span>
<span class="line"><span style="color:#79B8FF">`/review-blog-post-multi-agent @content/posts/blog-post-slug.md`</span><span style="color:#E1E4E8"> or </span><span style="color:#79B8FF">`path/to/blog-post.md`</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8;font-weight:bold">**Mode Selection Logic:**</span></span>
<span class="line"><span style="color:#FFAB70">-</span><span style="color:#E1E4E8"> If </span><span style="color:#79B8FF">`$1`</span><span style="color:#E1E4E8"> is provided AND not empty → Direct Review Mode</span></span>
<span class="line"><span style="color:#FFAB70">-</span><span style="color:#E1E4E8"> If </span><span style="color:#79B8FF">`$1`</span><span style="color:#E1E4E8"> is empty or missing → Auto-Selection Mode</span></span></code></pre>
<p>If <code>$1</code> is provided, use the file directly. If it's missing, run auto-selection. The command now does both modes in one place—finds the next post OR reviews the one you specify.</p>
<h2>The Result</h2>
<p>The consolidation was a net improvement: from 2 commands totaling 734 lines to 1 command with 632 lines. That's -102 lines while adding more functionality.</p>
<p>Here's what changed:</p>
<ul>
<li><strong>Tracking logic</strong>: Moved from the wrapper command into <code>project_docs/blog-review-tracking.json</code> as reusable content</li>
<li><strong>Auto-selection logic</strong>: Now part of the main command instead of a separate wrapper</li>
<li><strong>Architecture</strong>: No nested dependencies—just one command that can operate in two modes</li>
<li><strong>Usage</strong>: I can run <code>/review-blog-post-multi-agent</code> for auto-selection OR <code>/review-blog-post-multi-agent @content/posts/specific-post.md</code> for direct review</li>
</ul>]]></content:encoded>
            <category>opencode</category>
        </item>
    </channel>
</rss>