<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>N+1 Blog</title>
		<description>Technical blog about .NET, AI, software architecture, and developer productivity. Insights, tutorials, and best practices for modern software development.</description>
		<link>https://nikiforovall.blog/</link>
		<atom:link href="https://nikiforovall.blog/feed.xml" rel="self" type="application/rss+xml" />
		
			<item>
				<title>The Browser Automation Ecosystem for AI Agents: CLIs, Cloud Browsers, and AI-Native QA</title>
				<description>&lt;h2 id=&quot;tldr&quot;&gt;TL;DR&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-cli&quot;&gt;Playwright CLI&lt;/a&gt; made a bet back in April 2026: stop streaming DOM/accessibility trees into an agent’s context window through MCP tool calls, and instead persist browser state to disk as compact YAML that the agent reads on demand. Three months on, &lt;a href=&quot;https://github.com/vercel-labs/agent-browser&quot;&gt;Vercel Labs independently made the identical bet&lt;/a&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-browser&lt;/code&gt;. That’s not a coincidence — it’s the shape the whole ecosystem is converging on. This post maps where every other piece fits: cloud browser infra, AI-native test reporting, and the consumer-facing agentic browsers that are a different animal entirely.&lt;/p&gt;

&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;My &lt;a href=&quot;/ai/2025/09/06/playwright-claude-code-testing/&quot;&gt;previous post on Playwright + Claude Code&lt;/a&gt; tracked one specific transition — Playwright’s MCP server giving way to a CLI-first approach for coding agents. That was useful in isolation, but it’s one tool in a much bigger space. Since then I kept running into adjacent tools solving overlapping problems: Vercel shipping their own browser CLI, Anthropic pushing Claude for Chrome to GA, and a wave of “AI-native” test reporting startups layering LLM analysis on top of Playwright/Cypress CI runs.&lt;/p&gt;

&lt;p&gt;This post is a landscape map, not a tutorial. Three categories, what each tool actually is, and — because a lot of the marketing in this space overlaps — a decision guide for which one you actually reach for.&lt;/p&gt;

&lt;h2 id=&quot;the-three-categories&quot;&gt;The three categories&lt;/h2&gt;

&lt;div class=&quot;mermaid&quot;&gt;
flowchart TD
    A[&quot;Coding agent&lt;br /&gt;(Claude Code, Cursor, etc.)&quot;]

    subgraph L[&quot;1. Automation primitives&quot;]
        PC[&quot;Playwright CLI&quot;]
        AB[&quot;agent-browser (Vercel Labs)&quot;]
        SH[&quot;Stagehand&quot;]
        BU[&quot;Browser Use&quot;]
        SK[&quot;Skyvern&quot;]
        CLOUD[&quot;optional cloud execution:&lt;br /&gt;Browserbase, Browser Use Cloud,&lt;br /&gt;Vercel Sandbox&quot;]
        CUR[&quot;Cursor Cloud Agents&lt;br /&gt;(coding agent, not automation infra)&quot;]
    end

    subgraph P[&quot;2. Consumer / enterprise agentic browsers&quot;]
        CC[&quot;Claude for Chrome&quot;]
        OP[&quot;OpenAI Operator&quot;]
        ED[&quot;Edge Copilot Mode&quot;]
    end

    subgraph Q[&quot;3. AI-native QA reporting&quot;]
        RP[&quot;ReportPortal&quot;]
        TD[&quot;TestDino&quot;]
        CU[&quot;Currents.dev&quot;]
        AT[&quot;Allure TestOps&quot;]
        TO[&quot;Testomat.io&quot;]
    end

    A --&amp;gt; L
    L --&amp;gt;|&quot;CI results feed into&quot;| Q
    L -.-&amp;gt;|&quot;same underlying mechanics,&lt;br /&gt;different execution context&quot;| P
&lt;/div&gt;

&lt;h2 id=&quot;1-automation-primitives--how-does-an-agent-drive-a-browser&quot;&gt;1. Automation primitives — “how does an agent drive a browser”&lt;/h2&gt;

&lt;p&gt;This is the layer your coding agent actually calls.&lt;/p&gt;

&lt;h3 id=&quot;playwright-cli&quot;&gt;Playwright CLI&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-cli&quot;&gt;Playwright CLI&lt;/a&gt; — Microsoft’s CLI-first tool, covered in depth in my &lt;a href=&quot;/ai/2025/09/06/playwright-claude-code-testing/&quot;&gt;earlier post&lt;/a&gt;. Since then: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run-code&lt;/code&gt; now accepts a file argument instead of only inline snippets, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;show --annotate&lt;/code&gt; adds visual/structural annotations for agents, a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;drop&lt;/code&gt; command handles drag-and-drop, and the CLI self-checks for stale installed Skills. Worth correcting my own record here: Playwright’s &lt;strong&gt;MCP server is not deprecated&lt;/strong&gt; — Microsoft still recommends it for exploratory, self-healing, long-running agentic sessions that benefit from persistent state. CLI+Skills is the pick for high-throughput coding-agent workflows where token budget dominates. ~11.8k stars, 235 dependent projects — real adoption, still smaller than the OSS agent-browser frameworks below.&lt;/p&gt;

&lt;h4 id=&quot;what-it-actually-feels-like-to-use&quot;&gt;What it actually feels like to use&lt;/h4&gt;

&lt;p&gt;Setup is two commands — no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.mcp.json&lt;/code&gt;, no server process to keep alive:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @playwright/cli@latest
playwright-cli &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--skills&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That second command drops a Skill into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.claude/skills/&lt;/code&gt; (or the equivalent for Copilot/Cursor/Codex), so the agent discovers every command by reading &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;playwright-cli --help&lt;/code&gt; on its own — no tool schema to hand-wire into context.&lt;/p&gt;

&lt;p&gt;You can drive it by hand first, the same way the agent will:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;playwright-cli open https://demo.playwright.dev/todomvc/ &lt;span class=&quot;nt&quot;&gt;--headed&lt;/span&gt;
playwright-cli &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Buy groceries&quot;&lt;/span&gt;
playwright-cli press Enter
playwright-cli &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Water flowers&quot;&lt;/span&gt;
playwright-cli press Enter
playwright-cli check e21
playwright-cli screenshot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The part that actually changes how an agent works is what comes back after &lt;em&gt;every&lt;/em&gt; command — not a blob of accessibility-tree JSON stuffed into the response, but a pointer to a YAML file on disk:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&amp;gt; playwright-cli goto https://demo.playwright.dev/todomvc/
### Page
- Page URL: https://demo.playwright.dev/todomvc/
- Page Title: TodoMVC
### Snapshot
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The agent only opens that file when it actually needs to reason about the DOM — e.g. to find the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e21&lt;/code&gt; ref for a checkbox — instead of re-parsing a full tree on every single turn. That’s the whole “state on disk, not tool schemas in context” bet from the TL;DR, made concrete: ten actions in a row cost ten cheap CLI invocations plus however many snapshot reads the agent actually decides it needs, not ten full-page trees round-tripped through the model.&lt;/p&gt;

&lt;p&gt;In practice, an end-to-end run from Claude Code looks like this:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&amp;gt; Use playwright skills to test the &quot;add todo&quot; flow on
  https://demo.playwright.dev/todomvc, then write a Playwright test for it.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Claude works it like a person would — open the page, snapshot to get refs, type and submit two todos, check one off, screenshot as evidence — and only after the manual walkthrough succeeds does it emit the automated test:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;playwright-cli run-code &lt;span class=&quot;nt&quot;&gt;--filename&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;verify.ts   &lt;span class=&quot;c&quot;&gt;# sanity-check a snippet before committing to it&lt;/span&gt;
playwright-cli generate-locator e21            &lt;span class=&quot;c&quot;&gt;# turn a ref into a stable Playwright locator&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@playwright/test&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;nf&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;can add and complete a todo&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;goto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://demo.playwright.dev/todomvc/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByPlaceholder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;What needs to be done?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Buy groceries&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByPlaceholder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;What needs to be done?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;press&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Enter&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByPlaceholder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;What needs to be done?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Water flowers&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByPlaceholder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;What needs to be done?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;press&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Enter&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByRole&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;listitem&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;hasText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Buy groceries&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByRole&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;checkbox&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByRole&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;listitem&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;hasText&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Buy groceries&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toHaveClass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;/completed/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Same manual-exploration-then-generate-test shape as the MCP workflow in &lt;a href=&quot;/ai/2025/09/06/playwright-claude-code-testing/#from-manual-testing-to-automation&quot;&gt;my earlier post&lt;/a&gt; — the difference isn’t the workflow, it’s that every step along the way is a lightweight CLI call and a snapshot pointer instead of a tool-call response carrying the accessibility tree. If something misbehaves mid-run, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;playwright-cli show&lt;/code&gt; opens a live dashboard with a screencast of the session — useful for actually watching what the agent is doing rather than reading a wall of tool-call logs after the fact.&lt;/p&gt;

&lt;h3 id=&quot;agent-browser-vercel-labs&quot;&gt;agent-browser (Vercel Labs)&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/vercel-labs/agent-browser&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vercel-labs/agent-browser&lt;/code&gt;&lt;/a&gt; — a Rust CLI from Vercel Labs, same philosophy as Playwright CLI: accessibility-tree snapshots with persistent element refs (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@e1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@e2&lt;/code&gt;) instead of re-serializing the DOM every turn.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Integrations&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@agent-browser/sandbox&lt;/code&gt; for running itself inside ephemeral Vercel Sandbox microVMs, plus its own MCP server (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-browser mcp&lt;/code&gt;) for tools that still expect one. Documented to work with Claude Code, Cursor, Codex, Copilot, Windsurf, Gemini CLI, Goose, and OpenCode.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Adoption&lt;/strong&gt;: 37.9k stars — bigger than Playwright CLI, though that’s partly Vercel’s reach rather than a signal of deeper coding-agent adoption specifically. One credible independent write-up: &lt;a href=&quot;https://www.pulumi.com/blog/self-verifying-ai-agents-vercels-agent-browser-in-the-ralph-wiggum-loop/&quot;&gt;Pulumi’s engineering blog&lt;/a&gt; used it with Claude Code in a self-verifying CI loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;what-it-actually-feels-like-to-use-1&quot;&gt;What it actually feels like to use&lt;/h4&gt;

&lt;p&gt;Setup mirrors Playwright CLI’s two-command simplicity:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; agent-browser
agent-browser &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt;   &lt;span class=&quot;c&quot;&gt;# downloads Chrome for Testing on first run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Driving the same TodoMVC flow by hand, using refs from a snapshot instead of Playwright CLI’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e1&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e2&lt;/code&gt; style:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;agent-browser open https://demo.playwright.dev/todomvc/
agent-browser snapshot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;- textbox &quot;What needs to be done?&quot; [ref=e1]
- list &quot;todo-list&quot; [ref=e2]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;agent-browser fill @e1 &lt;span class=&quot;s2&quot;&gt;&quot;Buy groceries&quot;&lt;/span&gt;
agent-browser press Enter
agent-browser fill @e1 &lt;span class=&quot;s2&quot;&gt;&quot;Water flowers&quot;&lt;/span&gt;
agent-browser press Enter
agent-browser snapshot
agent-browser check @e6
agent-browser screenshot todo.png
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Same shape as the Playwright CLI session — snapshot, act on a ref, snapshot again — just with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@e1&lt;/code&gt;-style refs instead of bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;e1&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fill&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;press&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;press&lt;/code&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open&lt;/code&gt; on the first line pays a browser-launch cost once; everything after it is fast because a background daemon stays warm between commands. Under the hood that daemon is Rust talking straight to the Chrome DevTools Protocol (CDP — the interface Chrome/Chromium expose for controlling pages, dispatching input, reading network traffic), where Playwright’s own bindings reach the same protocol through a bundled Node.js driver process instead. It detects an existing Playwright or Puppeteer install to reuse the Chrome binary, but doesn’t depend on either at runtime.&lt;/p&gt;

&lt;p&gt;Where it diverges from Playwright CLI is what happens once the exploration succeeds. Playwright CLI’s agent loop ends by emitting an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@playwright/test&lt;/code&gt; file, because Playwright ships a full test runner. agent-browser doesn’t — there’s no test-runner equivalent — so the natural artifact is a repeatable script of the same CLI commands via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;batch&lt;/code&gt;, which also cuts the per-command process overhead:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;agent-browser batch &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;open&quot;, &quot;https://demo.playwright.dev/todomvc/&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;fill&quot;, &quot;@e1&quot;, &quot;Buy groceries&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;press&quot;, &quot;Enter&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;fill&quot;, &quot;@e1&quot;, &quot;Water flowers&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;press&quot;, &quot;Enter&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;check&quot;, &quot;@e6&quot;]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;[&quot;screenshot&quot;, &quot;todo.png&quot;]&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s the practical tradeoff versus Playwright CLI: a faster, dependency-free daemon and one CLI surface for both exploration and repeatable automation, in exchange for giving up Playwright’s mature test-runner ecosystem (fixtures, trace viewer, CI reporters) — you’d still reach for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@playwright/test&lt;/code&gt; or Stagehand if you actually want a maintained regression suite out of the session.&lt;/p&gt;

&lt;h3 id=&quot;stagehand&quot;&gt;Stagehand&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/browserbase/stagehand&quot;&gt;Stagehand&lt;/a&gt; (Browserbase, MIT, 23.3k stars) — four primitives: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;extract&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;observe&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent&lt;/code&gt;. Where Playwright CLI and agent-browser are CLIs a coding agent shells out to, Stagehand is a TypeScript SDK you write code against directly — the LLM decision loop lives inside your own script, not behind a separate command-line process.&lt;/p&gt;

&lt;h4 id=&quot;what-it-actually-feels-like-to-use-2&quot;&gt;What it actually feels like to use&lt;/h4&gt;

&lt;p&gt;Setup is a scaffolding CLI, not a global install:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npx create-browser-app
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;my-stagehand-app
&lt;span class=&quot;nb&quot;&gt;cp&lt;/span&gt; .env.example .env   &lt;span class=&quot;c&quot;&gt;# add OPENAI_API_KEY, BROWSERBASE_API_KEY, etc.&lt;/span&gt;
npm start
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That scaffolds a runnable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.ts&lt;/code&gt; using the same four primitives. Adapted to the TodoMVC flow from the previous two sections:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Stagehand&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@browserbasehq/stagehand&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;zod&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;LOCAL&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// or &quot;BROWSERBASE&quot; for a cloud session&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;pages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;goto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://demo.playwright.dev/todomvc/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;act&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;type &apos;Buy groceries&apos; into the new-todo box and press Enter&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;act&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;type &apos;Water flowers&apos; into the new-todo box and press Enter&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;observeResult&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;observe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;what can I click to mark the first todo as done?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;actResult&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;act&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;observeResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// replay the previewed action, not a fresh prompt&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;actResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cacheStatus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// &quot;MISS&quot; the first time, &quot;HIT&quot; on every rerun after&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;extract&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;how many completed todos are there?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;observe()&lt;/code&gt; is the preview step — it returns a candidate action without running it, so you can inspect or approve it before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act()&lt;/code&gt; executes it. Run the same script a second time and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act()&lt;/code&gt; reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cacheStatus: &quot;HIT&quot;&lt;/code&gt;: Browserbase’s server-side cache keys on the instruction, page content, and options, so a repeated call skips the LLM entirely and replays deterministically — until the page’s structure actually changes, at which point it falls back to a fresh model call automatically. That’s the “prompt-first while exploring, code-first once stable” pitch made concrete: the same three lines cost real inference on the first run and roughly zero on every rerun after, with no separate step to convert exploration into a locator.&lt;/p&gt;

&lt;p&gt;For the same “just describe the outcome” shape as Playwright CLI’s and agent-browser’s agent-driven sessions, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent()&lt;/code&gt; wraps the whole flow:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;agent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;stagehand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Add two todos, &apos;Buy groceries&apos; and &apos;Water flowers&apos;, then mark the first one complete.&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The competitive angle versus the CLI tools: Playwright CLI and agent-browser hand an agent raw commands and a snapshot file, and the agent decides what to do next every turn — there’s no built-in mechanism for skipping repeated LLM calls once a flow is known-good. Stagehand’s caching does that natively, and switching from a local Chromium to a cloud one is a one-line config change (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;env: &quot;BROWSERBASE&quot;&lt;/code&gt;) rather than a separate sandbox package or infra wiring. &lt;a href=&quot;https://www.browserbase.com/&quot;&gt;Browserbase&lt;/a&gt; itself is managed headless Chromium at scale, plus a Search API (Exa-powered), a Fetch API, and “Agent Identity” — Free / Developer $20/mo / Startup $99/mo / custom Scale, backed by a $40M Series B at ~$300M valuation.&lt;/p&gt;

&lt;h3 id=&quot;browser-use&quot;&gt;Browser Use&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/browser-use/browser-use&quot;&gt;Browser Use&lt;/a&gt; (MIT, Python, 102.8k GitHub stars as of writing — actively maintained, latest release &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.13.3&lt;/code&gt;, July 2026) takes a different shape than everything above it. Where Stagehand exposes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;extract&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;observe&lt;/code&gt; as primitives you compose yourself, Browser Use is fully agentic by default: you hand it one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;task&lt;/code&gt; string and an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent.run()&lt;/code&gt; loop drives the whole session end-to-end, closer in spirit to Skyvern than to Stagehand.&lt;/p&gt;

&lt;h4 id=&quot;what-it-actually-feels-like-to-use-3&quot;&gt;What it actually feels like to use&lt;/h4&gt;

&lt;p&gt;Install is a package, not a CLI-first tool like the previous two sections:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;uv add browser-use   &lt;span class=&quot;c&quot;&gt;# or: pip install browser-use&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Requires Python 3.11+. The minimal shape is a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent&lt;/code&gt;, not a sequence of act/type/click calls:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;asyncio&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;browser_use&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BrowserProfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ChatBrowserUse&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;agent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Go to https://demo.playwright.dev/todomvc/, add two todos &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;
            &lt;span class=&quot;sh&quot;&gt;&quot;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Buy groceries&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; and &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Water flowers&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;, then mark the first one complete.&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;llm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;ChatBrowserUse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;openai/gpt-5.5&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;browser_profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;BrowserProfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headless&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;history&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;history&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;final_result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;asyncio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Compare that to the Stagehand &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent()&lt;/code&gt; call covering the identical task in the previous section — same one-sentence-task shape, but there’s no lower-level &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;act&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;observe&lt;/code&gt; escape hatch to drop into if you want a specific step to be deterministic instead of re-planned by the model. Extensibility works the other direction: you register custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Tools()&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@tools.action&lt;/code&gt; decorators that the agent can call mid-run (e.g. a “read this file” or “post to Slack” action), rather than scripting page-level steps yourself.&lt;/p&gt;

&lt;p&gt;There’s also a scaffolding command, same idea as Stagehand’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create-browser-app&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;uvx browser-use init &lt;span class=&quot;nt&quot;&gt;--template&lt;/span&gt; default   &lt;span class=&quot;c&quot;&gt;# writes a runnable browser_use_default.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And a separate CLI surface (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;browser-use&lt;/code&gt;, powered by a companion project called Browser Harness) that drops you into a scripted REPL for direct page control rather than task-driven automation — plus a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;browser-use skill&lt;/code&gt; command that registers itself as a Claude Code/Codex Skill, letting a coding agent drive a connected browser directly instead of going through the Python &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent&lt;/code&gt; class at all.&lt;/p&gt;

&lt;p&gt;The competitive pitch: multi-provider LLM support out of the box (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ChatBrowserUse&lt;/code&gt; proxies OpenAI, Anthropic, and Google models behind one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BROWSER_USE_API_KEY&lt;/code&gt;, plus direct &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ChatOpenAI&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ChatAnthropic&lt;/code&gt; classes and local Ollama models), and a company-run benchmark (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;browser-use/benchmark&lt;/code&gt;, “BU Bench V1”) comparing model success rates — the project is as much a benchmarking/model-optimization effort as a browser-automation library. Where it’s genuinely thinner than Stagehand: no documented cache-and-replay mechanism, so every run costs a full agentic loop of LLM calls rather than a cheap cache hit on repeat.&lt;/p&gt;

&lt;p&gt;Unlike Stagehand’s one-line &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;env: &quot;BROWSERBASE&quot;&lt;/code&gt; swap, cloud isn’t a constructor flag on the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent&lt;/code&gt; class here — &lt;strong&gt;Browser Use Cloud&lt;/strong&gt; (&lt;a href=&quot;https://cloud.browser-use.com/&quot;&gt;cloud.browser-use.com&lt;/a&gt;) is a separate hosted product: submit a natural-language task over the API, it spins up a remote browser and agent, and gives you a live, watchable session URL. Pay-as-you-go from $0.06/browser-hour, or $100/mo Starter.&lt;/p&gt;

&lt;h3 id=&quot;skyvern&quot;&gt;Skyvern&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/skyvern-ai/skyvern&quot;&gt;Skyvern&lt;/a&gt; (AGPL-3.0, Python, 22.1k stars) is the odd one out in this category: not a CLI, not a library you &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import&lt;/code&gt; and drive yourself, but a local &lt;strong&gt;service&lt;/strong&gt; — a FastAPI server plus a bundled web UI — that you talk to through a Python/TS SDK or the UI directly. Its pitch is vision-LLM-assisted form-filling at scale (insurance quotes, government forms, checkout flows), with a hybrid grounding model rather than a pure no-selectors one: you can drive it with plain CSS/XPath, a natural-language prompt, or “AI fallback” (selector first, AI only on failure).&lt;/p&gt;

&lt;h4 id=&quot;what-it-actually-feels-like-to-use-4&quot;&gt;What it actually feels like to use&lt;/h4&gt;

&lt;p&gt;Quickstart spins up the whole service, not just a package:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pip &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;skyvern[all]&quot;&lt;/span&gt;
skyvern quickstart   &lt;span class=&quot;c&quot;&gt;# starts the FastAPI server + UI at http://localhost:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The SDK then layers Playwright-level control and AI-augmented calls on the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page&lt;/code&gt; object — the interesting part is that they compose, so you choose per-step how much AI you want:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;skyvern&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Skyvern&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;skyvern&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Skyvern&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# or Skyvern(api_key=...) for Skyvern Cloud
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;browser&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;skyvern&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;launch_cloud_browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;page&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;get_working_page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;goto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;https://demo.playwright.dev/todomvc/&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;click&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;the new-todo input box&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# AI-located, no selector written
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;agent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;run_task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;                                 &lt;span class=&quot;c1&quot;&gt;# or hand off the whole flow as one task
&lt;/span&gt;    &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Add two todos, &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Buy groceries&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; and &lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Water flowers&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;, then mark the first one complete.&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;extract&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;how many completed todos are there?&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;schema&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page.act(prompt)&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page.extract(prompt, schema)&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page.validate(prompt)&lt;/code&gt; are the Stagehand-shaped primitives here; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page.agent.run_task(...)&lt;/code&gt; is the Browser-Use-shaped one-liner. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page.agent.run_workflow(workflow_id)&lt;/code&gt; invokes a saved, structured multi-step definition built in Skyvern’s no-code workflow builder (Browser Task/Action blocks, data extraction, loops, HTTP requests, custom code) — the closest thing here to a “record once, replay forever” artifact, though it’s explicitly authored rather than auto-generated from a successful AI run.&lt;/p&gt;

&lt;p&gt;Skyvern’s blog/technical-report material references a “Route Memorization” feature that auto-converts a successful AI path into a deterministic Playwright script — not documented in the README, so treat it as unconfirmed rather than a shipped feature.&lt;/p&gt;

&lt;p&gt;The genuinely distinctive bits versus Browser Use and Stagehand: first-class 2FA/TOTP handling (QR, email, SMS) with dedicated docs, password-manager integration (Bitwarden today, 1Password/LastPass planned), an MCP server, and Zapier/Make/N8N integrations — this is a tool built around unattended, credentialed form-filling rather than exploratory browsing. Its 85.85% WebVoyager score is self-reported by Skyvern; a neutral aggregator (&lt;a href=&quot;https://leaderboard.steel.dev/&quot;&gt;Steel’s leaderboard&lt;/a&gt;) repeats the same number, but that’s a compilation of vendor-reported runs, not an independent re-execution — treat it as directionally useful, not a controlled benchmark.&lt;/p&gt;

&lt;p&gt;Cloud is &lt;a href=&quot;https://app.skyvern.com/&quot;&gt;Skyvern Cloud&lt;/a&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Skyvern(api_key=...)&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Skyvern.local()&lt;/code&gt;) — the README is explicit that the OSS core is feature-complete except for what it calls anti-bot measures: proxy rotation and CAPTCHA solving are cloud-exclusive, alongside no-infra hosting and parallel run capacity.&lt;/p&gt;

&lt;h3 id=&quot;cursor-cloud-agents&quot;&gt;Cursor Cloud Agents&lt;/h3&gt;

&lt;p&gt;A different shape of “cloud browser” than everything above: &lt;strong&gt;Cursor Cloud Agents&lt;/strong&gt; (&lt;a href=&quot;https://cursor.com/cloud&quot;&gt;cursor.com/cloud&lt;/a&gt;) isn’t infra you point a browser-automation library at — it’s a coding-agent product that happens to bundle a browser, not a standalone automation service like Browserbase or Browser Use Cloud.&lt;/p&gt;

&lt;p&gt;You dispatch a task from wherever you already work, not just the IDE: Cursor Desktop’s “Cloud” option, the web dashboard at cursor.com/agents, the mobile apps, an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@cursor&lt;/code&gt; mention on a GitHub/GitLab/Bitbucket PR comment, Slack, Teams, a Linear/Jira assignment, or the API. Each task gets its own ephemeral cloud VM — full desktop and terminal, not just a headless container — that clones the repo onto a working branch, installs dependencies (agent-led setup, a saved snapshot, or a custom Dockerfile via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursor/environment.json&lt;/code&gt;), and gets network access. Nothing is streamed to you live by default: a dashboard lists running agents, and completion produces a merge-ready PR plus a video recording of the agent’s actual desktop/browser session, screenshots, and logs — though you can grab remote-desktop control of a still-running VM if you want to watch or intervene.&lt;/p&gt;

&lt;p&gt;The browser-verification part isn’t a flag you set — it’s the agent deciding, mid-task, to open the app it just changed and click through it to check the result, then attaching that session as evidence rather than handing off its first untested attempt. Cursor’s own launch post claimed 30% of their internally merged PRs came from agents running this way; CEO Michael Truell has since cited 35%+ in press coverage as adoption grew. Billing is API/token-metered per model rather than a separate SKU — cloud agents always run in Max Mode, gated by a spend limit you set once, and the capability itself is bundled into Ultra/Teams/Enterprise plans.&lt;/p&gt;

&lt;h2 id=&quot;2-consumer--enterprise-agentic-browsers--same-tech-different-job&quot;&gt;2. Consumer / enterprise agentic browsers — same tech, different job&lt;/h2&gt;

&lt;p&gt;These use the same LLM-drives-a-browser mechanics as category 1, but the split holds up: they act inside &lt;em&gt;your&lt;/em&gt; authenticated browser session — your email, your accounts, your cookies — not a disposable automation instance, and they ship as closed products from a single vendor rather than a library you wire into your own agent loop.&lt;/p&gt;

&lt;h3 id=&quot;claude-for-chrome&quot;&gt;Claude for Chrome&lt;/h3&gt;

&lt;p&gt;A Chrome extension, not a separate app — install it from the Chrome Web Store and it acts inside your existing logged-in tabs. Still in beta (Anthropic’s own product page calls it “a beta feature with unique risks”), available on all paid plans rather than gated to a specific tier. Two concrete capabilities beyond one-shot “do this now” prompts: scheduled tasks that re-run on a daily/weekly/monthly cadence, and multi-tab workflows where you drag tabs into a Claude tab group so the agent can read and act across all of them at once.&lt;/p&gt;

&lt;p&gt;The programmable primitive underneath, if you want to build the same click/type/screenshot loop yourself rather than use the extension, is Anthropic’s &lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool&quot;&gt;Computer Use tool&lt;/a&gt; on the Developer Platform (also available via Bedrock and Vertex): the model returns a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tool_use&lt;/code&gt; action — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;screenshot&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;left_click&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scroll&lt;/code&gt; — as JSON, your code executes it against a VM/container you run, and you feed the resulting screenshot back in.&lt;/p&gt;

&lt;p&gt;Worth knowing: &lt;a href=&quot;https://cyberscoop.com/claude-chrome-extension-allows-plugins-to-hijack-ai/&quot;&gt;CyberScoop reported&lt;/a&gt; a real vulnerability chain — a permissive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*.claude.ai&lt;/code&gt; origin allowlist combined with a DOM XSS bug in a CAPTCHA component let another browser extension achieve zero-click prompt injection and hijack the agent’s session. Disclosed December 2025, patched in extension v1.0.41. This class of risk (an agent that can read and act on any page you have open) is structural to the category, not unique to Anthropic’s implementation — expect similar disclosures from other in-browser agents as they mature.&lt;/p&gt;

&lt;h3 id=&quot;openai-operator--chatgpt-agent&quot;&gt;OpenAI Operator / ChatGPT agent&lt;/h3&gt;

&lt;p&gt;Folded into ChatGPT’s unified agent mode. Third-party reviews through 2026 have been mixed to critical (overcautious, constant confirmation prompts) — treat that sentiment as opinion-blog territory, not a controlled study.&lt;/p&gt;

&lt;h3 id=&quot;microsoft-edge-copilot-mode&quot;&gt;Microsoft Edge Copilot Mode&lt;/h3&gt;

&lt;p&gt;Enterprise/IT-policy-gated agentic browsing for Edge for Business, still in limited preview. Notable mainly as market context: Microsoft is betting on agentic browsing at the OS/browser level with Edge, while separately shipping Playwright CLI as the dev-tool-layer bet — same company, two different audiences.&lt;/p&gt;

&lt;p&gt;One neutral cross-tool reference point, if you want actual numbers instead of scattered vendor claims: &lt;a href=&quot;https://leaderboard.steel.dev/&quot;&gt;Steel’s open leaderboard&lt;/a&gt; tracks Skyvern, Browser Use, Operator, and Claude Computer Use against WebVoyager/OSWorld/Online-Mind2Web. It’s still self-reported per source, dated and attributed rather than independently re-run, and the maintainers themselves flag that WebVoyager has saturated (&amp;gt;90% scores common at the top) — useful for rough ordering, not a rigorous head-to-head.&lt;/p&gt;

&lt;h2 id=&quot;3-ai-native-qa-reporting&quot;&gt;3. AI-native QA reporting&lt;/h2&gt;

&lt;p&gt;These tools don’t drive a browser. They consume Playwright/Cypress CI output and add an AI layer on top — failure classification, flakiness scoring, root-cause tagging. Easy to conflate with category 1 because they’re all “AI + Playwright,” but they solve “why did this test fail” rather than “how do I drive this test.”&lt;/p&gt;

&lt;h3 id=&quot;reportportal&quot;&gt;ReportPortal&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://reportportal.io/&quot;&gt;ReportPortal&lt;/a&gt; — open-source (Apache-2.0, EPAM-originated), self-hosted-first test analytics platform, and the most technically substantive AI story of the group: its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;service-auto-analyzer&lt;/code&gt; component runs similarity/clustering search over indexed logs and stack traces (Elasticsearch-backed) to auto-assign new failures to previously triaged defect types — genuine ML/information-retrieval, not an LLM wrapper. Ships an official MCP server (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reportportal/reportportal-mcp-server&lt;/code&gt;) exposing auto-analysis, unique-error-analysis, quality gates, and launch/log queries to any MCP-speaking agent. Free to self-host indefinitely — the only tool in this group without a paywall between you and the AI features.&lt;/p&gt;

&lt;h3 id=&quot;testdino&quot;&gt;TestDino&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://testdino.com/&quot;&gt;TestDino&lt;/a&gt; — AI failure classification into four buckets (actual bug / UI-change failure / flaky / misc) with confidence scores, per-test stability tracking, GitHub PR annotations. The one thing that separates it from older reporting tools: a genuine open-source &lt;a href=&quot;https://github.com/testdino-hq/testdino-mcp&quot;&gt;MCP server&lt;/a&gt; with 27 tools letting Claude/Cursor/Copilot query test-run and root-cause data directly — confirmed as a real engineering artifact, not just marketing copy, though I found no independent user reviews (G2/Reddit/HN) corroborating how well it works in practice.&lt;/p&gt;

&lt;h3 id=&quot;currentsdev&quot;&gt;Currents.dev&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://currents.dev/&quot;&gt;Currents.dev&lt;/a&gt; — a Playwright/Cypress-focused CI dashboard and test orchestrator: aggregates run results, auto-balances specs across CI machines (claiming up to ~50% faster than native Playwright sharding), flaky-test detection, and analytics/regression trends. Its AI story is an official MCP server plus a Playwright “agent skill” and IDE extension, letting Claude/Cursor/Copilot query historical test data directly — the flaky-detection and error-classification mechanisms themselves aren’t documented as ML- or LLM-based, so treat “AI-driven” there as marketing rather than a verified technique.&lt;/p&gt;

&lt;h3 id=&quot;allure-testops&quot;&gt;Allure TestOps&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://qameta.io/&quot;&gt;Allure TestOps&lt;/a&gt; (Qameta) — the paid layer on top of the widely-used open-source Allure Report standard: manual + automation test management, CI/BTS/TMS integrations, high-load result storage. Its AI story is younger and narrower than ReportPortal’s — no native ML failure analysis — built instead around an official MCP server (public beta since release 26.1.1, ~13 tools) for creating/searching test cases via its query language and managing launches, results, and test plans from Claude Desktop, Claude Code, or Cursor.&lt;/p&gt;

&lt;h3 id=&quot;testomatio&quot;&gt;Testomat.io&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://testomat.io/&quot;&gt;Testomat.io&lt;/a&gt; — a manual + automated test-management tool built around AI throughout the workflow rather than one bolt-on feature: AI Failure Clusterization (groups automated failures by detected pattern, shipped on all tiers including free — older claims that it “lacks AI analysis” are stale) and an embedded AI Agent Test Assistant. No first-party MCP server surfaced as of this research, so its AI integration is in-product rather than agent-facing.&lt;/p&gt;

&lt;p&gt;Feature and pricing comparison, verified directly against each vendor’s own site/docs/pricing page:&lt;/p&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;AI/ML mechanism&lt;/th&gt;
&lt;th&gt;Agent integration&lt;/th&gt;
&lt;th&gt;Entry price&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://reportportal.io/pricing/saas/&quot;&gt;ReportPortal&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ML/similarity clustering (Elasticsearch-backed auto-analysis)&lt;/td&gt;
&lt;td&gt;Official MCP server (Docker, stdio/HTTP)&lt;/td&gt;
&lt;td&gt;Free, self-hosted (Apache-2.0)&lt;/td&gt;
&lt;td&gt;SaaS also available, ~$570–600/mo (Startup tier), if you don&apos;t want to self-host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://testdino.com/&quot;&gt;TestDino&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4-bucket failure classification with confidence scores&lt;/td&gt;
&lt;td&gt;Open-source MCP server, 27 tools&lt;/td&gt;
&lt;td&gt;$39/mo (annual) / $49/mo (monthly)&lt;/td&gt;
&lt;td&gt;Free tier: 5k executions/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://currents.dev/pricing&quot;&gt;Currents.dev&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Undocumented mechanism (marketed as &quot;AI-driven&quot; flaky detection)&lt;/td&gt;
&lt;td&gt;Official MCP server + Playwright agent skill&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;Usage-based, 10k results included, $5/1k overage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://qameta.io/cloud-pricing&quot;&gt;Allure TestOps&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None native — AI story is the MCP server, not ML analysis&lt;/td&gt;
&lt;td&gt;Official MCP server, public beta, ~13 tools&lt;/td&gt;
&lt;td&gt;$39/user/mo, sliding to $30/user/mo at scale&lt;/td&gt;
&lt;td&gt;Per-seat, not per-execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://testomat.io/pricing/&quot;&gt;Testomat.io&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AI Failure Clusterization + AI Agent Test Assistant&lt;/td&gt;
&lt;td&gt;None confirmed&lt;/td&gt;
&lt;td&gt;$30/mo/user ($27 annual)&lt;/td&gt;
&lt;td&gt;AI clusterization ships on all tiers, including free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;None of these generate or drive tests — they’re analytics/reporting layers on top of whatever ran the browser in category 1.&lt;/p&gt;

&lt;h2 id=&quot;decision-guide&quot;&gt;Decision guide&lt;/h2&gt;

&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you&apos;re...&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing/exploring a web app from Claude Code, token budget matters&lt;/td&gt;
&lt;td&gt;Playwright CLI or &lt;code&gt;agent-browser&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running the automation itself inside ephemeral Vercel Sandbox microVMs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agent-browser&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want natural-language actions with fallback to deterministic code, plus caching&lt;/td&gt;
&lt;td&gt;Stagehand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want one task string to drive the whole session instead of composing primitives&lt;/td&gt;
&lt;td&gt;Browser Use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automating credentialed, form-heavy flows at scale (2FA, password managers)&lt;/td&gt;
&lt;td&gt;Skyvern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need production-grade cloud browser sessions behind an API&lt;/td&gt;
&lt;td&gt;Browserbase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want a coding agent that verifies its own change by driving a browser, fully in the cloud&lt;/td&gt;
&lt;td&gt;Cursor Cloud Agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want AI triage on top of existing Playwright/Cypress CI failures&lt;/td&gt;
&lt;td&gt;ReportPortal — free, self-hosted, and the most substantive ML story (TestDino/Currents/Allure otherwise, if you&apos;d rather pay for SaaS than run infra)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want a human-facing agentic browser, not a coding-agent tool&lt;/td&gt;
&lt;td&gt;Claude for Chrome&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The clearest signal in this space right now isn’t any single tool — it’s that Playwright CLI’s and Vercel’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;agent-browser&lt;/code&gt; independently converged on the same architecture: state on disk, not tool schemas in context. That’s a strong sign the MCP-tool-call pattern for &lt;em&gt;driving&lt;/em&gt; a browser is being displaced by CLI-first design specifically for coding agents, even as MCP stays relevant for other integration points.&lt;/p&gt;

&lt;p&gt;Category 1 itself splits further than “primitives” implies: Playwright CLI, agent-browser, and Stagehand hand you building blocks to compose; Browser Use and Skyvern go further and take a single task string, running the whole agentic loop themselves. That’s a real architectural choice, not a maturity gradient — which one you want depends on whether you need a specific step to stay deterministic or you’re fine with the model re-planning every turn.&lt;/p&gt;

&lt;p&gt;There’s a second, almost inverse convergence in category 3: TestDino, Currents.dev, ReportPortal, and Allure TestOps are all independently shipping MCP servers, but not to drive anything — they let an agent query test-run and root-cause data instead. Opposite job from category 1’s story, same protocol. Everything else in this post — consumer agentic browsers — sits in an adjacent lane that’s easy to lump in under “AI browser tools” but solves a different problem: acting inside &lt;em&gt;your&lt;/em&gt; session rather than a disposable automation instance.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-cli&quot;&gt;https://github.com/microsoft/playwright-cli&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/vercel-labs/agent-browser&quot;&gt;https://github.com/vercel-labs/agent-browser&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/browserbase/stagehand&quot;&gt;https://github.com/browserbase/stagehand&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/browser-use/browser-use&quot;&gt;https://github.com/browser-use/browser-use&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/skyvern-ai/skyvern&quot;&gt;https://github.com/skyvern-ai/skyvern&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.browserbase.com/&quot;&gt;https://www.browserbase.com/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cloud.browser-use.com/&quot;&gt;https://cloud.browser-use.com/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cursor.com/cloud&quot;&gt;https://cursor.com/cloud&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://reportportal.io/&quot;&gt;https://reportportal.io/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://testdino.com/&quot;&gt;https://testdino.com/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/testdino-hq/testdino-mcp&quot;&gt;https://github.com/testdino-hq/testdino-mcp&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://currents.dev/&quot;&gt;https://currents.dev/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://qameta.io/&quot;&gt;https://qameta.io/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://testomat.io/&quot;&gt;https://testomat.io/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://leaderboard.steel.dev/&quot;&gt;https://leaderboard.steel.dev/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool&quot;&gt;https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cyberscoop.com/claude-chrome-extension-allows-plugins-to-hijack-ai/&quot;&gt;https://cyberscoop.com/claude-chrome-extension-allows-plugins-to-hijack-ai/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/ai/2025/09/06/playwright-claude-code-testing/&quot;&gt;My earlier post: Testing with Playwright and Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/2026/07/05/browser-automation-agents-ecosystem.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/2026/07/05/browser-automation-agents-ecosystem.html</guid>
			</item>
		
			<item>
				<title>Mapping the AI Context &amp; Memory Ecosystem: context-mode, graphify, cognee, OpenMetadata</title>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Four repos, two markets that turn out to share one primitive — the graph. &lt;a href=&quot;https://github.com/mksglu/context-mode&quot;&gt;context-mode&lt;/a&gt; and &lt;a href=&quot;https://github.com/Graphify-Labs/graphify&quot;&gt;graphify&lt;/a&gt; compress/structure what an agent sees in a single session; &lt;a href=&quot;https://github.com/topoteretes/cognee&quot;&gt;cognee&lt;/a&gt; builds persistent cross-session memory on the same graph-RAG substrate; &lt;a href=&quot;https://github.com/open-metadata/OpenMetadata&quot;&gt;OpenMetadata&lt;/a&gt; is the established data-catalog platform now repositioning as a “context layer for AI.”&lt;/p&gt;

&lt;iframe src=&quot;/assets/ai-context-ecosystem/report.html&quot; width=&quot;100%&quot; height=&quot;800px&quot; frameborder=&quot;0&quot; style=&quot;border: 1px solid #ddd; border-radius: 8px; margin: 20px 0;&quot;&gt;
&lt;/iframe&gt;

&lt;p&gt;&lt;strong&gt;Full report&lt;/strong&gt;: &lt;a href=&quot;/assets/ai-context-ecosystem/report.html&quot;&gt;ai-context-ecosystem report →&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-this-report&quot;&gt;Why this report&lt;/h2&gt;

&lt;p&gt;Every one of these tools claims to solve “the agent doesn’t remember/see enough” problem, but they attack it from different layers — one CLI session, one agent’s memory store, or an entire org’s data warehouse. It wasn’t obvious from the READMEs alone whether any of them actually compete with each other, so I put together a working scratchpad with the &lt;a href=&quot;/ai/2026/06/08/scratch/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt;&lt;/a&gt; CLI: one file per repo and per competitor category, a cross-cutting-gaps section, and a use-cases section built from web research rather than invented scenarios.&lt;/p&gt;
</description>
				<pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/2026/07/04/ai-context-ecosystem.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/2026/07/04/ai-context-ecosystem.html</guid>
			</item>
		
			<item>
				<title>tmux-message-bus: Giving Claude Code Instances a Mailbox</title>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tmux-message-bus&lt;/code&gt;&lt;/a&gt; is an experiment: a durable, ordered, at-least-once message bus. It lets independent Claude Code instances, each in its own tmux window or session, hand work to each other and trust it arrives. The filesystem (one SQLite-WAL database) is the transport; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send-keys&lt;/code&gt; is demoted to an optional doorbell. The whole thing is built out of Claude Code’s lifecycle hooks, a small CLI, and config. There’s no programmatic hook into the agent itself. This post walks through how that works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus&quot;&gt;github.com/nikiforovall/tmux-message-bus&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;two-ways-to-make-a-coding-agent-programmable&quot;&gt;Two ways to make a coding agent programmable&lt;/h2&gt;

&lt;p&gt;If you want to extend a terminal coding agent today, there are broadly two models on offer.&lt;/p&gt;

&lt;p&gt;The first is an imperative API into the agent itself. &lt;a href=&quot;https://pi.dev&quot;&gt;pi&lt;/a&gt; is the clearest example: extensions are TypeScript modules, loaded without a compile step, that “hook directly into the agent’s reasoning loop, event lifecycle, and terminal UI.” You register tools the LLM can call, subscribe to lifecycle events that fire &lt;em&gt;inside&lt;/em&gt; the agent loop, rewrite the compaction pipeline, gate tool calls, and the LLM never sees any of it. &lt;a href=&quot;https://opencode.ai/docs/sdk/&quot;&gt;opencode&lt;/a&gt; takes a similar shape from a different angle: it runs a headless HTTP server with an OpenAPI spec and ships an official &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@opencode-ai/sdk&lt;/code&gt;, plus a &lt;a href=&quot;https://opencode.ai/docs/plugins/&quot;&gt;plugin system&lt;/a&gt; where each plugin is a TS module exporting hook objects. Either way, you’re writing code that runs in-process, with first-class access to the agent’s internals.&lt;/p&gt;

&lt;p&gt;The second model is declarative composition from the outside. This is Claude Code. You get lifecycle hooks (shell commands that fire on events), plugins, skills, MCP servers, and a CLI. There is no SDK that drops you inside the reasoning loop. A hook is a process the harness spawns at a defined moment; it reads JSON on stdin and can return a small structured decision on stdout. That’s the whole contract. It’s lower-level and language-agnostic, and it never lets you write code that the agent runs &lt;em&gt;as&lt;/em&gt; itself.&lt;/p&gt;

&lt;p&gt;The imperative model is more ergonomic, no question. Whether it’s more &lt;em&gt;powerful&lt;/em&gt; is less obvious, and anyway with Claude Code you don’t get to pick: the declarative kit is what’s on offer. So the honest question is how much you can actually build with it. I went looking for a problem that would stress it.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-agents-are-islands&quot;&gt;The problem: agents are islands&lt;/h2&gt;

&lt;p&gt;Open three Claude Code sessions in tmux and you have three islands. There is no clean way for one to hand a task to another and know it landed. Claude Code’s native multi-agent tools — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Agent&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SendMessage&lt;/code&gt;, the Task tools — only address subagents &lt;em&gt;within a single process’s tree&lt;/em&gt;. They cannot reach an independent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude&lt;/code&gt; running in another tmux window. Nothing native crosses that boundary.&lt;/p&gt;

&lt;p&gt;The common workaround is to script &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tmux send-keys&lt;/code&gt; to type into another pane’s input box. It’s fragile in exactly the way you’d expect: the receiver is an Ink/React TUI in nondeterministic state, so your keystrokes collide with streaming output, permission prompts, and autocomplete. There’s no acknowledgement, no ordering, no durability. If the target window moved or the agent is mid-turn, the message is just gone.&lt;/p&gt;

&lt;p&gt;So I wanted a real mailbox: durable, ordered, at-least-once delivery between agents that never have to be awake at the same time. In the imperative model you might build this as a plugin holding a socket. Here, there’s nowhere to hold a socket. There’s no long-lived process I control. There are only hooks.&lt;/p&gt;

&lt;h2 id=&quot;hooks-as-primitives&quot;&gt;Hooks as primitives&lt;/h2&gt;

&lt;p&gt;The trick is to stop thinking of hooks as callbacks and start thinking of them as primitives you compose a system out of. Claude Code fires hooks at distinct lifecycle moments, and each one maps onto a piece of a message bus. SessionStart registers an instance: a hook writes it into a shared registry (who I am, my pid, my current pane), and sweeps stale state while it’s there. Stop delivers: when an agent finishes a turn, a hook drains its mailbox, claiming new messages, injecting them, marking them done. UserPromptSubmit handles the doorbell wake, which I’ll get to below. SessionEnd garbage-collects on graceful exit, keeping the database bounded.&lt;/p&gt;

&lt;p&gt;None of these knows about a “bus.” Each is a few lines of shell calling a small Node CLI that owns the SQLite database. The bus is what emerges when you wire these events to a shared store. It’s assembled out of lifecycle events rather than coded into a runtime.&lt;/p&gt;

&lt;div style=&quot;max-width:760px;margin:0 auto&quot;&gt;
&lt;div class=&quot;mermaid&quot;&gt;
flowchart LR
    subgraph hostA[&quot;tmux window A&quot;]
        A[&quot;Agent A&lt;br /&gt;(Claude instance)&quot;]
    end
    subgraph hostB[&quot;tmux window B&quot;]
        B[&quot;Agent B&lt;br /&gt;(Claude instance)&quot;]
    end
    DB[(&quot;bus.db&lt;br /&gt;SQLite WAL&lt;br /&gt;agents + messages&quot;)]
    A -- &quot;1 send (durable INSERT)&quot; --&amp;gt; DB
    A -. &quot;2 doorbell: send-keys «bus»&lt;br /&gt;(best-effort wake)&quot; .-&amp;gt; B
    B -- &quot;3 claim → act → ack&quot; --&amp;gt; DB
    DB -. registry / liveness .- A
    DB -. registry / liveness .- B
&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The one genuinely load-bearing trick lives in the Stop hook. A Stop hook can return &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{decision: &quot;block&quot;, reason: &quot;...&quot;}&lt;/code&gt;, which tells the harness not to stop yet, with the reason supplied. That re-prompt is the delivery mechanism. When an agent tries to end its turn, the hook claims any waiting mail and feeds the bodies back as the reason, so the agent keeps going and acts on them. A clear-on-read claim means the &lt;em&gt;next&lt;/em&gt; Stop sees an empty mailbox and is allowed to terminate, so there’s no infinite loop. Everything else is plumbing around that one observed behavior.&lt;/p&gt;

&lt;h2 id=&quot;two-ideas-that-make-it-robust&quot;&gt;Two ideas that make it robust&lt;/h2&gt;

&lt;p&gt;Building on top of a TUI you don’t control forces some discipline. Two distributed-systems ideas did the heavy lifting.&lt;/p&gt;

&lt;p&gt;The first is to separate delivery from notification. Sending a message is a durable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INSERT&lt;/code&gt; into SQLite, and that’s the entire delivery guarantee: once the row is committed, the message can’t be lost. The doorbell, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send-keys&lt;/code&gt; of a tiny fixed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;«bus»&lt;/code&gt; sentinel to wake an idle peer, is purely best-effort. If it fails, nothing breaks; the message is already stored and gets picked up on the receiver’s next turn. This inverts the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send-keys&lt;/code&gt; hack. The filesystem is the real transport, and keystrokes are just a nudge that lowers latency. A garbled doorbell costs you latency, never a message.&lt;/p&gt;

&lt;div style=&quot;max-width:760px;margin:0 auto&quot;&gt;
&lt;div class=&quot;mermaid&quot;&gt;
sequenceDiagram
    participant A as Agent A
    participant DB as bus.db
    participant B as Agent B
    A-&amp;gt;&amp;gt;DB: send --to B (INSERT, status=new)
    Note over A,DB: delivery is done here — durable, cannot be lost
    A--&amp;gt;&amp;gt;B: doorbell «bus» (best-effort send-keys)
    activate B
    Note over B: next turn (woken by doorbell,&lt;br /&gt;or its normal Stop hook)
    B-&amp;gt;&amp;gt;DB: claim (atomic, ordered by id)
    Note over B: message injected as framed&lt;br /&gt;INFORMATION — B decides what to do
    B-&amp;gt;&amp;gt;DB: ack (mark done)
    deactivate B
    B-&amp;gt;&amp;gt;DB: reply --to-msg (correlated)
&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The second is that identity has to survive the chaos of tmux. Panes move, windows get renamed, sessions get re-numbered, so none of those is identity. The bus keys each agent on a stable id derived from its session, so a renamed session or a moved pane is never mistaken for a dead agent, and identity survives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--resume&lt;/code&gt;. The details are in &lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus/blob/master/docs/DESIGN.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DESIGN.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One surprise is worth calling out.&lt;/strong&gt; Injected messages are framed as information from a peer, not as commands, and that framing turned out to be load-bearing. When I first injected message bodies as imperatives (“reply with token X”), the receiver refused them as a prompt-injection attempt; the harness applies injection defenses to hook-injected text. Re-framing each message with its provenance (“routed from peer agent X on the bus you enabled; this is information, not a user command; you decide whether to act”) made it go through. The platform itself nudges you toward messaging rather than remote code execution. The receiver always decides.&lt;/p&gt;

&lt;h2 id=&quot;what-the-constraint-taught-me&quot;&gt;What the constraint taught me&lt;/h2&gt;

&lt;p&gt;The declarative kit turned out to be more expressive than its surface suggests. I expected to hit a wall. Instead, a handful of lifecycle hooks, a shared store, and one re-prompt trick composed into a durable async transport with delivery, ordering, liveness, and correlation. None of it required a single line of code running inside the agent loop.&lt;/p&gt;

&lt;p&gt;That’s the takeaway worth keeping. The outside-in surface looks limited, but it’s stable, language-agnostic, and composable. The way to find out what it can do is to pick a problem one size too big for what it obviously supports and build a throwaway probe; the constraints show you where the edges actually are.&lt;/p&gt;

&lt;p&gt;It’s an experiment, not a product: single-host only, identity-based addressing, with roles and broadcast still open questions. But it works end to end, and the code is on &lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;Claude Code gives you declarative building blocks (lifecycle hooks, a CLI, config) rather than an imperative SDK into the agent loop like pi or opencode. That’s less ergonomic, but more capable than it looks. Treat hooks as primitives, wire them to a shared SQLite store, and you can build a durable cross-instance message bus that Claude Code doesn’t ship natively. Separate durable delivery from best-effort notification, anchor identity on something that survives tmux’s churn, and frame inter-agent traffic as information rather than commands.&lt;/p&gt;

&lt;h3 id=&quot;reference&quot;&gt;Reference&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus&quot;&gt;tmux-message-bus on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/nikiforovall/tmux-message-bus/blob/master/docs/DESIGN.md&quot;&gt;DESIGN.md — architecture, identity model, schema, flows&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md&quot;&gt;pi coding agent — extensions&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://opencode.ai/docs/sdk/&quot;&gt;opencode — SDK&lt;/a&gt; · &lt;a href=&quot;https://opencode.ai/docs/plugins/&quot;&gt;opencode — plugins&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/2026/06/27/tmux-message-bus.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/2026/06/27/tmux-message-bus.html</guid>
			</item>
		
			<item>
				<title>scratch: Structured Scratchpads for Coding Agents</title>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; organizes the &lt;em&gt;temporary&lt;/em&gt; knowledge an agent produces — notes, snippets, command output, intermediate artifacts — into a scratchpad: a folder plus a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratchpad.json&lt;/code&gt; manifest, kept out of your source tree. It’s a thin metadata layer over the filesystem, not a database.&lt;/p&gt;

&lt;p&gt;Install with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bun add -g @nikiforovall/scratchpad&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs &amp;amp; live demo&lt;/strong&gt;: &lt;a href=&quot;https://nikiforovall.blog/scratchpad/&quot;&gt;nikiforovall.blog/scratchpad/&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-files-the-context-window-is-a-real-limit&quot;&gt;Why files: the context window is a real limit&lt;/h2&gt;

&lt;p&gt;One constraint shapes everything else: &lt;strong&gt;agents work better when they write things down.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The context window is the agent’s only short-term memory — the running transcript of the task so far is all it can actually “see.” That transcript is finite. Once it fills up, the oldest parts drop off to make room: findings, decisions, the command output from twenty steps ago. There’s no warning; the model just quietly forgets. Long-horizon tasks fail less because the model is incapable and more because the memory it needed is gone.&lt;/p&gt;

&lt;p&gt;The fix is almost boring: &lt;strong&gt;externalize that memory to the filesystem.&lt;/strong&gt; Write the plan to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; file. Write findings to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; file. Now the agent re-reads exactly what it needs, when it needs it, instead of hoping it’s still in context — and a markdown file is durable, greppable, diffable, and survives a compaction. “Markdown files are all you need” is becoming a real pattern, not a hack.&lt;/p&gt;

&lt;p&gt;So agents should write to disk. The open question is &lt;em&gt;what happens to those files next.&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-problem-temporary-agent-knowledge-has-nowhere-to-go&quot;&gt;The problem: temporary agent knowledge has nowhere to go&lt;/h2&gt;

&lt;p&gt;Spend a session with a coding agent and watch what it produces. It greps the codebase and finds five relevant files. It writes a quick design note before touching code. It runs the test suite and the failure output is what it reasons about next. It sketches a mermaid diagram to make sense of a flow.&lt;/p&gt;

&lt;p&gt;All of that is real, useful work. And almost none of it sticks around.&lt;/p&gt;

&lt;p&gt;Agents generate a lot of temporary knowledge per session, and nothing keeps track of it. It ends up scattered across the repo, buried in chat history, or lost when the context window rolls over.&lt;/p&gt;

&lt;p&gt;The knowledge is &lt;em&gt;temporary&lt;/em&gt; — tied to a session or a task, not to the project forever — but “temporary” shouldn’t mean “lost” or “scattered.”&lt;/p&gt;

&lt;h2 id=&quot;the-solution-a-scratchpad-is-just-a-folder&quot;&gt;The solution: a scratchpad is just a folder&lt;/h2&gt;

&lt;p&gt;A scratchpad is a folder containing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratchpad.json&lt;/code&gt; manifest. The folder path &lt;em&gt;is&lt;/em&gt; its identity — there’s no central store, no daemon, no SQLite file hidden away under your config. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; is a thin metadata layer over the filesystem: it creates pads, registers files with a description and type, and shows you what’s there.&lt;/p&gt;

&lt;p&gt;The agent writes files with its normal tools. Then it &lt;em&gt;registers&lt;/em&gt; each one:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;scratch new &lt;span class=&quot;s2&quot;&gt;&quot;auth-refactor&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--dir&lt;/span&gt; _scratchpads
&lt;span class=&quot;c&quot;&gt;# → creates _scratchpads/auth-refactor/ + scratchpad.json&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# agent writes _scratchpads/auth-refactor/findings.md with its normal tools, then:&lt;/span&gt;
scratch add &lt;span class=&quot;s2&quot;&gt;&quot;auth-refactor&quot;&lt;/span&gt; findings.md &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--desc&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;where the current token flow breaks&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--type&lt;/span&gt; note &lt;span class=&quot;nt&quot;&gt;--tag&lt;/span&gt; auth,investigation
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--desc&lt;/code&gt; is the point: it captures &lt;strong&gt;why this file exists&lt;/strong&gt;, not just that it does. Months later — or two compactions later — that one line is the difference between a useful artifact and a mystery file.&lt;/p&gt;

&lt;p&gt;Two things make this pleasant in practice. First, &lt;strong&gt;no lock-in&lt;/strong&gt;: it’s files on disk. Delete the folder and the knowledge is gone; the CLI never authors, copies, or moves your content. Second, &lt;strong&gt;a human can actually read it&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-demo-see-what-the-agent-gathered&quot;&gt;The demo: see what the agent gathered&lt;/h2&gt;

&lt;p&gt;Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch ui&lt;/code&gt; and you get a read-only, two-pane viewer in a “Lab Notebook” theme that auto-detects light/dark:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/nikiforovall/scratchpad/main/assets/demo.png&quot; alt=&quot;scratch viewer&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Markdown is rendered (with a raw/rendered toggle), code is syntax-highlighted, mermaid diagrams draw, images show inline. It opens a native window by default and falls back to your browser if the native backend isn’t available.&lt;/p&gt;

&lt;p&gt;Scratchpads are also &lt;strong&gt;shareable&lt;/strong&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch export&lt;/code&gt; bundles a whole pad into a single self-contained HTML file — file contents embedded, no server needed — that you can hand to a teammate or link from anywhere. Here’s one exported live:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it&lt;/strong&gt;: &lt;a href=&quot;https://nikiforovall.blog/scratchpad/demo-pad.html&quot;&gt;a scratchpad, exported →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No digging through transcripts to find what the agent learned. You open the viewer — or the exported file — and browse.&lt;/p&gt;

&lt;h2 id=&quot;wiring-it-into-claude-code&quot;&gt;Wiring it into Claude Code&lt;/h2&gt;

&lt;p&gt;The repo doubles as a Claude Code plugin marketplace. Add it and the agent gets a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; skill that teaches it &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; to drive the CLI — so it reaches for a scratchpad on its own when a task starts generating keepable knowledge:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/plugin marketplace add NikiforovAll/scratchpad
/plugin install scratchpad@scratchpad
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The plugin ships the skill; the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; CLI itself still comes from the install above:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;bun add &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @nikiforovall/scratchpad
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From there the agent runs the loop — create, write, register, browse — without you micromanaging it.&lt;/p&gt;

&lt;h2 id=&quot;planning-with-scratchpads&quot;&gt;Planning with scratchpads&lt;/h2&gt;

&lt;p&gt;The plugin ships a second skill, &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;planning-with-scratchpad&lt;/code&gt;&lt;/strong&gt;, that puts a real workflow on top of the raw CLI. Its goal: use scratchpads as &lt;strong&gt;external memory for complex, multi-session tasks&lt;/strong&gt; — the kind where a single Plan Mode pass isn’t enough and you need research, decisions, and rationale to survive context limits and session boundaries.&lt;/p&gt;

&lt;p&gt;The convention is one pad per task under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_plans/&lt;/code&gt;, and &lt;strong&gt;one concern per file&lt;/strong&gt; rather than a sprawling 200-line &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan.md&lt;/code&gt; — the index: goal, phases, status, errors. Kept lean; it’s the map, not the territory.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;research-&amp;lt;topic&amp;gt;.md&lt;/code&gt; — sources and findings, one file per distinct topic.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;decisions.md&lt;/code&gt; — options, the choice, and &lt;em&gt;why&lt;/em&gt; (the rationale future-you needs).&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch-&amp;lt;label&amp;gt;.md&lt;/code&gt; — disposable working notes and prototypes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flow it encourages: research into files first, &lt;strong&gt;outline every phase and task in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan.md&lt;/code&gt; and get your approval before any code is written&lt;/strong&gt;, then batch-create the tasks. Because every file is registered with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt;, you open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch ui&lt;/code&gt; and watch the plan take shape in the viewer — the agent’s thinking laid out as browsable files instead of buried in a long transcript. You can read the plan and steer it before it turns into code.&lt;/p&gt;

&lt;details style=&quot;border:1px solid #d0d7de;border-radius:6px;padding:.6em 1em;margin:1em 0;background:rgba(127,127,127,.06);&quot;&gt;
  &lt;summary style=&quot;cursor:pointer;font-weight:600;list-style:none;&quot;&gt;▶ Show the full &lt;code&gt;planning-with-scratchpad&lt;/code&gt; SKILL.md &lt;span style=&quot;font-weight:400;opacity:.65;&quot;&gt;(click to expand)&lt;/span&gt;&lt;/summary&gt;

  &lt;div class=&quot;language-markdown highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;planning-with-scratchpad&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Use when user explicitly requests planning with a scratchpad, or asks for persistent tracking of a complex task that the human can browse visually. Backs planning files with the `scratch` CLI so a pad&apos;s files are registered and viewable.&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;disable-model-invocation&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;

&lt;span class=&quot;gh&quot;&gt;# Planning with Scratchpad&lt;/span&gt;

Use persistent markdown files as external memory for complex tasks. Files survive context limits and session boundaries. The files live in a &lt;span class=&quot;gs&quot;&gt;**scratchpad**&lt;/span&gt; (a &lt;span class=&quot;sb&quot;&gt;`_plans/`&lt;/span&gt; folder + manifest) so the human can browse the plan in the visual viewer.

&lt;span class=&quot;gs&quot;&gt;**Load the `scratch` skill first**&lt;/span&gt; — it owns all CLI mechanics (&lt;span class=&quot;sb&quot;&gt;`new`&lt;/span&gt;, &lt;span class=&quot;sb&quot;&gt;`add`&lt;/span&gt;, &lt;span class=&quot;sb&quot;&gt;`ls`&lt;/span&gt;, &lt;span class=&quot;sb&quot;&gt;`show`&lt;/span&gt;, &lt;span class=&quot;sb&quot;&gt;`ui`&lt;/span&gt;). This skill adds the planning conventions on top.

Use persistent files for planning, &lt;span class=&quot;sb&quot;&gt;`TaskCreate`&lt;/span&gt; for tracking execution. Before closing session, use &lt;span class=&quot;sb&quot;&gt;`AskUserQuestion`&lt;/span&gt; to confirm findings, decisions, and deliverables are satisfactory.

&lt;span class=&quot;gu&quot;&gt;## Bundled References&lt;/span&gt;

Read these when you need deeper guidance on a specific aspect:
&lt;span class=&quot;p&quot;&gt;
-&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**[reference.md](reference.md)**&lt;/span&gt; — Context engineering principles (Manus-inspired). Read when designing how to structure planning files for a long-running or multi-agent task.
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**[examples.md](examples.md)**&lt;/span&gt; — Real task examples showing different file combinations. Read when unsure which files to create for a given task shape.

&lt;span class=&quot;gu&quot;&gt;## When to Use&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;
-&lt;/span&gt; User explicitly requests planning/tracking with a scratchpad
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; Complex multi-session tasks
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; Research-heavy work requiring persistent notes
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; Tasks where decisions and rationale need to be preserved

&lt;span class=&quot;gu&quot;&gt;## When NOT to Use&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;
-&lt;/span&gt; Simple single-session tasks (use Plan Mode instead)
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; Quick fixes or small changes
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; Tasks with clear requirements needing no research

&lt;span class=&quot;gu&quot;&gt;## Directory Convention&lt;/span&gt;

One pad per task, created in &lt;span class=&quot;sb&quot;&gt;`_plans/`&lt;/span&gt; with a date-prefixed folder:

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;
&lt;/span&gt;scratch new &quot;&amp;lt;task-name&amp;gt;&quot; --dir _plans --id &quot;$SESSION_ID&quot;
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

This creates &lt;span class=&quot;sb&quot;&gt;`_plans/YYYY-MM-DD-&amp;lt;slug&amp;gt;/`&lt;/span&gt;. Write files into that folder with your normal tools, then &lt;span class=&quot;gs&quot;&gt;**register each**&lt;/span&gt; with &lt;span class=&quot;sb&quot;&gt;`scratch add`&lt;/span&gt; so it appears in the viewer:

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;
&lt;/span&gt;_plans/
  2026-01-08-dark-mode-toggle/
    scratchpad.json          # manifest (managed by scratch)
    plan.md
    research-css-strategies.md
    decisions.md
  2026-01-09-api-auth-refactor/
    scratchpad.json
    plan.md
    research-auth-providers.md
    research-token-storage.md
    decision-oauth-vs-saml.md
    decision-session-strategy.md
    references.md
    scratch-migration-steps.md
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gs&quot;&gt;**Naming:**&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`YYYY-MM-DD-task-name`&lt;/span&gt; (kebab-case, concise description) — &lt;span class=&quot;sb&quot;&gt;`scratch new`&lt;/span&gt; derives this from the task name + date.

&lt;span class=&quot;gu&quot;&gt;## File Types — One Concern Per File&lt;/span&gt;

Each file owns a single concern. Never merge concerns into one file — a 200-line plan.md that also contains research notes, decisions, and scratch work is hard to navigate and easy to lose track of. Split early; you can always cross-reference with relative links. Register each file with the matching &lt;span class=&quot;sb&quot;&gt;`scratch add --type`&lt;/span&gt;.

| File                  | Concern                                      | Create when                                                                                                         | &lt;span class=&quot;sb&quot;&gt;`--type`&lt;/span&gt;    |
| --------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------- |
| &lt;span class=&quot;sb&quot;&gt;`plan.md`&lt;/span&gt;             | Goal, phases, status, errors                 | Always — this is the index                                                                                          | &lt;span class=&quot;sb&quot;&gt;`note`&lt;/span&gt;      |
| &lt;span class=&quot;sb&quot;&gt;`research-&amp;lt;topic&amp;gt;.md`&lt;/span&gt; | Sources, findings for one topic              | Any research needed. One file per distinct topic — e.g. &lt;span class=&quot;sb&quot;&gt;`research-auth-providers.md`&lt;/span&gt;, &lt;span class=&quot;sb&quot;&gt;`research-perf-benchmarks.md`&lt;/span&gt; | &lt;span class=&quot;sb&quot;&gt;`reference`&lt;/span&gt; |
| &lt;span class=&quot;sb&quot;&gt;`decisions.md`&lt;/span&gt;        | ADRs, options, rationale                     | Any non-obvious tradeoff. For large efforts, split per decision: &lt;span class=&quot;sb&quot;&gt;`decision-database-choice.md`&lt;/span&gt;                      | &lt;span class=&quot;sb&quot;&gt;`note`&lt;/span&gt;      |
| &lt;span class=&quot;sb&quot;&gt;`scratch-&amp;lt;label&amp;gt;.md`&lt;/span&gt;  | Drafts, working notes, exploratory code      | Complex reasoning or prototyping. Disposable — can be deleted after use                                             | &lt;span class=&quot;sb&quot;&gt;`snippet`&lt;/span&gt;   |
| &lt;span class=&quot;sb&quot;&gt;`&amp;lt;deliverable&amp;gt;.md`&lt;/span&gt;    | Final outputs                                | Reports, summaries, documentation                                                                                   | &lt;span class=&quot;sb&quot;&gt;`artifact`&lt;/span&gt;  |
| &lt;span class=&quot;sb&quot;&gt;`references.md`&lt;/span&gt;       | Links to external resources, docs, prior art | When multiple sources inform the work                                                                               | &lt;span class=&quot;sb&quot;&gt;`reference`&lt;/span&gt; |

Always pass &lt;span class=&quot;sb&quot;&gt;`--desc &quot;why this file exists&quot;`&lt;/span&gt; — it&apos;s the most valuable metadata in the viewer.

&lt;span class=&quot;gu&quot;&gt;### Splitting heuristic&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;
-&lt;/span&gt; If a section in any file exceeds ~80 lines, extract it into its own file
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; If you&apos;re about to add a second &lt;span class=&quot;sb&quot;&gt;`## Research:`&lt;/span&gt; or &lt;span class=&quot;sb&quot;&gt;`## Decision:`&lt;/span&gt; heading to an existing file, create a new file instead
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`plan.md`&lt;/span&gt; should stay lean — it&apos;s the map, not the territory. Link to detail files:
  &lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;markdown
&lt;/span&gt;  ## Research
&lt;span class=&quot;p&quot;&gt;  -&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Auth providers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;research-auth-providers.md&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;  -&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Performance&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;research-perf-benchmarks.md&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### plan.md Template&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;markdown
&lt;/span&gt;&lt;span class=&quot;gh&quot;&gt;# Plan: [Brief Description]&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## Goal&lt;/span&gt;
[One sentence describing the end state]

&lt;span class=&quot;gu&quot;&gt;## Phases&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] Phase 1: [Description]
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] Phase 2: [Description]
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] Phase 3: [Description]

&lt;span class=&quot;gu&quot;&gt;## Status&lt;/span&gt;
&lt;span class=&quot;gs&quot;&gt;**Current:**&lt;/span&gt; [What&apos;s happening now]

&lt;span class=&quot;gu&quot;&gt;## Decisions&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [Decision]: [Rationale]

&lt;span class=&quot;gu&quot;&gt;## Errors Encountered&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [Error]: [Resolution]
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### research.md Template&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;markdown
&lt;/span&gt;&lt;span class=&quot;gh&quot;&gt;# Research: [Topic]&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## Sources&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [Source]: [Key findings]

&lt;span class=&quot;gu&quot;&gt;## Findings&lt;/span&gt;
&lt;span class=&quot;gu&quot;&gt;### [Category]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [Finding]
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### decisions.md Template&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;markdown
&lt;/span&gt;&lt;span class=&quot;gh&quot;&gt;# Decisions: [Task]&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## [Decision Title]&lt;/span&gt;
&lt;span class=&quot;gs&quot;&gt;**Status:**&lt;/span&gt; Decided | Pending
&lt;span class=&quot;gs&quot;&gt;**Options:**&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;1.&lt;/span&gt; [Option A] - [Pros/Cons]
&lt;span class=&quot;p&quot;&gt;2.&lt;/span&gt; [Option B] - [Pros/Cons]

&lt;span class=&quot;gs&quot;&gt;**Choice:**&lt;/span&gt; [Selected option]
&lt;span class=&quot;gs&quot;&gt;**Rationale:**&lt;/span&gt; [Why]
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## Task System Integration&lt;/span&gt;

Planning files and tasks serve different stages:

| Stage             | Tool                | Purpose                                    |
| ----------------- | ------------------- | ------------------------------------------ |
| &lt;span class=&quot;gs&quot;&gt;**Investigation**&lt;/span&gt; | Planning Files      | Research, decisions, rationale, error logs |
| &lt;span class=&quot;gs&quot;&gt;**Execution**&lt;/span&gt;     | TaskCreate/TaskList | Decomposed work items with dependencies    |

&lt;span class=&quot;gu&quot;&gt;### Workflow&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;
&lt;/span&gt;1. scratch new &quot;&amp;lt;task&amp;gt;&quot; --dir _plans --id &quot;$SESSION_ID&quot;   # create the pad + plan.md
2. Research and document in planning files; `scratch add` each one
3. Make decisions, document rationale
4. Outline ALL tasks in plan.md first — present to user for review
5. After user approves, batch-create all tasks via TaskCreate
6. Link tasks back to planning docs
7. scratch ui &quot;&amp;lt;task&amp;gt;&quot; --dir _plans   # backgrounded, for the human
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### Outline Before Creating Tasks&lt;/span&gt;

Do NOT create tasks one by one as you discover them. Instead:
&lt;span class=&quot;p&quot;&gt;
1.&lt;/span&gt; Collect all phases and tasks during investigation, write them as a checklist in &lt;span class=&quot;sb&quot;&gt;`plan.md`&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;2.&lt;/span&gt; Present the full outline to the user via &lt;span class=&quot;sb&quot;&gt;`AskUserQuestion`&lt;/span&gt; — they need to see the complete picture before committing
&lt;span class=&quot;p&quot;&gt;3.&lt;/span&gt; Only after approval, batch-create &lt;span class=&quot;gs&quot;&gt;**all**&lt;/span&gt; tasks in a single pass — both phase-level and work-item tasks

This matters because the user needs to evaluate scope, reorder priorities, and spot gaps — which is impossible when tasks trickle in one at a time.

&lt;span class=&quot;gu&quot;&gt;### Task Hierarchy&lt;/span&gt;

Create tasks at two levels:
&lt;span class=&quot;p&quot;&gt;
1.&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**Phase tasks**&lt;/span&gt; — one per phase, owns the high-level goal. Mark complete when all child tasks are done.
&lt;span class=&quot;p&quot;&gt;2.&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**Work-item tasks**&lt;/span&gt; — concrete implementation steps within a phase.

Example batch after outline approval:
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;
&lt;/span&gt;TaskCreate: subject: &quot;Phase 1: Research auth providers&quot;
TaskCreate: subject: &quot;Phase 1.1: Compare OAuth2 libraries&quot;
TaskCreate: subject: &quot;Phase 1.2: Evaluate token storage options&quot;
TaskCreate: subject: &quot;Phase 2: Implement auth flow&quot;
TaskCreate: subject: &quot;Phase 2.1: Add OAuth2 login endpoint&quot;
TaskCreate: subject: &quot;Phase 2.2: Add token refresh middleware&quot;
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

Phase tasks give the user a progress overview; work-item tasks track actual execution.

&lt;span class=&quot;gu&quot;&gt;### Linking Tasks to Planning Documents&lt;/span&gt;

When batch-creating, reference the pad dir in each task:

&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;
&lt;/span&gt;TaskCreate:
  subject: &quot;Add OAuth2 login endpoint&quot;
  description: |
    Implement /auth/login endpoint.
    References: _plans/2026-01-23-auth-api/plan.md
  metadata:
    planDir: &quot;_plans/2026-01-23-auth-api&quot;
&lt;span class=&quot;p&quot;&gt;```&lt;/span&gt;

Tasks can have dependencies on each other, and all link back to the same pad directory for context.

&lt;span class=&quot;gu&quot;&gt;## Recommended Tools&lt;/span&gt;

| Tool                 | When to Use                                                                       |
| -------------------- | --------------------------------------------------------------------------------- |
| &lt;span class=&quot;gs&quot;&gt;**Explore subagent**&lt;/span&gt; | Search codebase, find patterns, understand existing structure before planning     |
| &lt;span class=&quot;gs&quot;&gt;**AskUserQuestion**&lt;/span&gt;  | Clarify requirements, validate assumptions, confirm decisions before proceeding   |
| &lt;span class=&quot;gs&quot;&gt;**scratch ui**&lt;/span&gt;       | Open the pad in the visual viewer (backgrounded) so the human can browse the plan |

Use these proactively during investigation to avoid wrong assumptions and wasted effort.

&lt;span class=&quot;gu&quot;&gt;## Success Criteria&lt;/span&gt;

Planning is complete when:
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] A pad exists under &lt;span class=&quot;sb&quot;&gt;`_plans/`&lt;/span&gt; with &lt;span class=&quot;sb&quot;&gt;`plan.md`&lt;/span&gt; (clear goal and phases) registered
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] All phases marked complete or explicitly deferred
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] Key decisions documented with rationale
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] Errors encountered are logged with resolutions
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] All files registered with &lt;span class=&quot;sb&quot;&gt;`scratch add`&lt;/span&gt; (visible in the viewer)
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; [ ] User confirms deliverables via &lt;span class=&quot;sb&quot;&gt;`AskUserQuestion`&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## Critical Rules&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### Refresh Goals When Context Gets Long&lt;/span&gt;

After many tool calls (~20+), re-read &lt;span class=&quot;sb&quot;&gt;`plan.md`&lt;/span&gt; before major decisions. This brings goals back into the attention window.

&lt;span class=&quot;gu&quot;&gt;### 1. Store, Don&apos;t Stuff&lt;/span&gt;
Large outputs go to files, not context. Keep paths in working memory, content in files.

&lt;span class=&quot;gu&quot;&gt;### 2. Log All Errors&lt;/span&gt;
Every error goes in plan.md under &quot;Errors Encountered&quot;. This builds knowledge and shows recovery.

&lt;span class=&quot;gu&quot;&gt;### 3. Decisions Need Rationale&lt;/span&gt;
Don&apos;t just record what you decided - record WHY. Future-you needs this context.

&lt;span class=&quot;gu&quot;&gt;### 4. Update Status Immediately&lt;/span&gt;
Mark phases complete as soon as they&apos;re done. Don&apos;t batch status updates.

&lt;span class=&quot;gu&quot;&gt;## Anti-Patterns&lt;/span&gt;

| Don&apos;t                                            | Do Instead                                               |
| ------------------------------------------------ | -------------------------------------------------------- |
| Create files in project root                     | Use a pad under &lt;span class=&quot;sb&quot;&gt;`_plans/YYYY-MM-DD-name/`&lt;/span&gt;                |
| Write files but forget to register them          | &lt;span class=&quot;sb&quot;&gt;`scratch add`&lt;/span&gt; each file so it shows in the viewer        |
| State goals once and forget                      | Re-read plan.md when context is long                     |
| Hide errors and retry silently                   | Log errors with resolution                               |
| Stuff everything in context                      | Store large content in files                             |
| Start executing immediately                      | Create plan.md first for complex tasks                   |
| Put research + decisions + notes in one big file | One file per concern — split by topic                    |
| Let plan.md grow past ~80 lines                  | Extract sections into dedicated files, link from plan.md |
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

&lt;/details&gt;

&lt;h2 id=&quot;also-works-on-pi&quot;&gt;Also works on pi&lt;/h2&gt;

&lt;p&gt;If you’re on the &lt;a href=&quot;https://pi.dev&quot;&gt;pi coding agent&lt;/a&gt;, the &lt;a href=&quot;https://www.npmjs.com/package/@nikiforovall/pi-scratchpad&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@nikiforovall/pi-scratchpad&lt;/code&gt;&lt;/a&gt; package ships the same skills plus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/scratch ui | export | stop&lt;/code&gt; commands for the viewer:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pi install npm:@nikiforovall/pi-scratchpad
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Same idea, same CLI underneath — give your agent’s working memory somewhere to land.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; turns the throwaway-but-useful output of an agent session into durable, inspectable, shareable knowledge: a folder, a manifest, and a viewer. It’s deliberately small — the agent does the writing, the filesystem does the storing, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scratch&lt;/code&gt; just keeps track.&lt;/p&gt;

&lt;h3 id=&quot;reference&quot;&gt;Reference&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/scratchpad&quot;&gt;scratch on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.blog/scratchpad/&quot;&gt;Documentation &amp;amp; live demo&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.blog/scratchpad/demo-pad.html&quot;&gt;A scratchpad, exported to a single HTML file&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.npmjs.com/package/@nikiforovall/scratchpad&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@nikiforovall/scratchpad&lt;/code&gt; on npm&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.npmjs.com/package/@nikiforovall/pi-scratchpad&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@nikiforovall/pi-scratchpad&lt;/code&gt; on npm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Mon, 08 Jun 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/2026/06/08/scratch.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/2026/06/08/scratch.html</guid>
			</item>
		
			<item>
				<title>pi-otel: OpenTelemetry Tracing for the Pi Coding Agent</title>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-otel&lt;/code&gt; is an OpenTelemetry extension for the &lt;a href=&quot;https://pi.dev&quot;&gt;pi coding agent&lt;/a&gt;. It emits one trace tree per user prompt — interaction, turns, LLM requests, and tool calls — over OTLP. The default target is a local .NET Aspire dashboard. Any OTLP-compatible backend (Grafana LGTM, Jaeger, Honeycomb) works too. Install with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi install npm:pi-otel&lt;/code&gt;, start with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/otel start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href=&quot;https://nikiforovall.blog/pi-otel/&quot;&gt;nikiforovall.blog/pi-otel/&lt;/a&gt;&lt;/p&gt;

&lt;center&gt;
  &lt;video src=&quot;https://github.com/user-attachments/assets/fe694bb6-138d-4cf8-866e-07ca9fc48d5a&quot; width=&quot;90%&quot; controls=&quot;controls&quot; /&gt;
&lt;/center&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-telemetry-for-a-coding-agent&quot;&gt;Why telemetry for a coding agent?&lt;/h2&gt;

&lt;p&gt;Pi is deliberately minimal. That minimalism is a feature — but it means there’s no built-in timeline view of what the agent actually did. You can read the session log, but a log is not a trace. You can count tokens from the session info modal, but a histogram is not a counter.&lt;/p&gt;

&lt;p&gt;Honestly, the first reason to install it is just curiosity. Watching a span tree populate in real time — seeing exactly which tool ran, how long it took, what the model returned — is a different kind of understanding than reading a session log after the fact. If you’ve ever wondered what pi is actually doing under the hood, a trace will show you in a way that no amount of log-reading will.&lt;/p&gt;

&lt;p&gt;If you’re running pi in a CI pipeline or as part of a larger workflow, “it worked or it didn’t” is not enough. You want to know which tool call ate 40 seconds, why a particular prompt costs twice as much as others, and whether errors cluster around a specific model or tool. That’s what spans give you.&lt;/p&gt;

&lt;h2 id=&quot;what-it-emits&quot;&gt;What it emits&lt;/h2&gt;

&lt;p&gt;One trace tree per user prompt:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pi.interaction                      ← root span (one per turn)
└── pi.turn                         ← one per agent turn
    ├── pi.llm_request              ← the LLM call
    └── pi.tool.&amp;lt;name&amp;gt;              ← one per tool execution
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every span carries GenAI semantic convention attributes — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_ai.system&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_ai.request.model&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_ai.usage.output_tokens&lt;/code&gt;, finish reason, tool call IDs, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gen_ai.conversation.id&lt;/code&gt; to correlate spans across a session.&lt;/p&gt;

&lt;p&gt;The session is intentionally &lt;strong&gt;not&lt;/strong&gt; a span. A pi session can run for hours; long-running root spans are an OTel anti-pattern. Instead, session identity travels as attributes on every span.&lt;/p&gt;

&lt;h2 id=&quot;quickstart&quot;&gt;Quickstart&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pi &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;npm:pi-otel
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then inside pi:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/otel start
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s it. pi-otel auto-detects an OTLP backend in this order: Aspire CLI → Docker → Podman. It spawns a local &lt;a href=&quot;https://aspire.dev/dashboard/standalone/&quot;&gt;.NET Aspire dashboard&lt;/a&gt; and opens it at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:18888&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Backend install options:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Aspire CLI&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;irm https://aspire.dev/install.ps1 | iex&lt;/code&gt; (Windows) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl -sSL https://aspire.dev/install.sh | bash&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt; or &lt;strong&gt;Podman&lt;/strong&gt; — any recent version&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-you-see-in-aspire&quot;&gt;What you see in Aspire&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/aspire-traces.png&quot; alt=&quot;Aspire traces view&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Traces&lt;/strong&gt; tab shows one root span per user prompt. Expand to see the full turn → LLM request → tool call nesting. Click any span for the attribute panel: model, token counts, finish reason, tool input/output.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/aspire-metrics.png&quot; alt=&quot;Aspire metrics view&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Metrics&lt;/strong&gt; tab (enabled with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;signals&quot;: { &quot;metrics&quot;: true }&lt;/code&gt;) shows histograms for LLM request latency, token usage (input/output/cache), and tool execution time.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/aspire-logs.png&quot; alt=&quot;Aspire logs view&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Structured Logs&lt;/strong&gt; tab (enabled with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;signals&quot;: { &quot;logs&quot;: true }&lt;/code&gt;) surfaces lifecycle events — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi.session.start&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi.session.end&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi.tool.error&lt;/code&gt; — plus the filtered OTel SDK diag bridge output.&lt;/p&gt;

&lt;h2 id=&quot;any-otlp-backend&quot;&gt;Any OTLP backend&lt;/h2&gt;

&lt;p&gt;Aspire is the default because it requires zero configuration. But pi-otel is built on open standards — the OTLP exporter works with anything.&lt;/p&gt;

&lt;h3 id=&quot;grafana-lgtm&quot;&gt;Grafana LGTM&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;samples/lgtm/&lt;/code&gt; directory ships a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker-compose.yml&lt;/code&gt; that spins up the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;otel-lgtm&lt;/code&gt; image (Tempo + Mimir + Loki in one container):&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;samples/lgtm
docker compose up &lt;span class=&quot;nt&quot;&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then inside pi:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/otel connect http://localhost:4317
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Traces land in Tempo, metrics in Mimir/Prometheus, logs in Loki — all pre-wired to Grafana.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/grafana-metrics.png&quot; alt=&quot;Grafana metrics dashboard&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/traces-tempo.png&quot; alt=&quot;Tempo traces&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026-05-16-pi-otel/loki-logs.png&quot; alt=&quot;Loki logs&quot; width=&quot;90%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;h3 id=&quot;other-backends&quot;&gt;Other backends&lt;/h3&gt;

&lt;p&gt;For Honeycomb, Grafana Cloud, Jaeger, or any other OTLP receiver, point the endpoint and add auth headers:&lt;/p&gt;

&lt;div class=&quot;language-jsonc highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// .pi/settings.json&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;otel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;endpoint&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://api.honeycomb.io&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;headers&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;x-honeycomb-team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;YOUR_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Or via env vars:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://api.honeycomb.io
&lt;span class=&quot;nv&quot;&gt;OTEL_EXPORTER_OTLP_HEADERS&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;x-honeycomb-team&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;YOUR_API_KEY
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;configuration-reference&quot;&gt;Configuration reference&lt;/h2&gt;

&lt;div class=&quot;language-jsonc highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// .pi/settings.json or ~/.pi/agent/settings.json&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;otel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;enabled&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;endpoint&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;http://localhost:4317&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;protocol&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;grpc&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;serviceName&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;pi&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;captureContent&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;metadata_only&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;sampleRatio&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;signals&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;traces&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;metrics&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;logs&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Key&lt;/th&gt;&lt;th&gt;Default&lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;enabled&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Master switch&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;endpoint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;http://localhost:4317&lt;/code&gt;&lt;/td&gt;&lt;td&gt;OTLP receiver URL&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;protocol&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;grpc&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;grpc&lt;/code&gt;, &lt;code&gt;http/protobuf&lt;/code&gt;, or &lt;code&gt;http/json&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;captureContent&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;metadata_only&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Content capture mode&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;sampleRatio&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;1.0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Head sampling ratio (0.0–1.0)&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;signals.traces&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Emit trace spans&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;signals.metrics&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Emit token/cost/latency histograms&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;signals.logs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Emit lifecycle log records + diag bridge&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OTEL_*&lt;/code&gt; env vars override settings. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PI_OTEL_DISABLED=1&lt;/code&gt; makes the extension a complete no-op.&lt;/p&gt;

&lt;h3 id=&quot;content-capture&quot;&gt;Content capture&lt;/h3&gt;

&lt;p&gt;By default (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;captureContent: &quot;metadata_only&quot;&lt;/code&gt;) spans carry token counts, model names, finish reasons, and tool call IDs — but not prompt or response text. Three modes:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th&gt;What lands on spans&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;metadata_only&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Token counts, model, finish reason, tool IDs&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;no_tool_content&lt;/code&gt;&lt;/td&gt;&lt;td&gt;+ LLM message content; no tool input/output&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;full&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Everything, including tool input/output (capped at 60 KB per attribute)&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;div class=&quot;language-jsonc highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;otel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;captureContent&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;full&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;extensibility--logs-from-your-own-pi-package&quot;&gt;Extensibility — logs from your own pi package&lt;/h2&gt;

&lt;p&gt;Since pi-otel registers a global OTel &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LoggerProvider&lt;/code&gt; at startup, any code in the same process can emit log records into the same stream. Two ways to do it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OTel API directly&lt;/strong&gt; — add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@opentelemetry/api-logs&lt;/code&gt; as a dependency, call the global provider. The call is a no-op if pi-otel isn’t loaded.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;logs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;SeverityNumber&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@opentelemetry/api-logs&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;logs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getLogger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;my-package&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;1.0.0&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;emit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;severityNumber&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;SeverityNumber&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;DEBUG&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;cache hit&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;tool.name&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;bash&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-otel:log&lt;/code&gt; event bus&lt;/strong&gt; — no import needed. From any pi extension, emit on pi’s shared event bus.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;pi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;events&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;emit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;pi-otel:log&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;eventName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;my-package.cache-hit&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;severity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;debug&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;cache hit&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;tool.name&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;bash&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Both land in Aspire Structured Logs alongside pi-otel’s own session and error events. The OTel API approach uses your own instrumentation scope; the event bus emits under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-otel&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;the-diagnostic-bridge&quot;&gt;The diagnostic bridge&lt;/h3&gt;

&lt;p&gt;When &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;signals.logs&lt;/code&gt; is enabled, pi-otel forwards the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@opentelemetry/*&lt;/code&gt; SDK’s own internal &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;diag&lt;/code&gt; messages to the same OTLP endpoint under the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@opentelemetry/diag&lt;/code&gt; instrumentation scope.&lt;/p&gt;

&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pi &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;npm:pi-otel
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then in pi:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/otel start         # spawn local Aspire dashboard
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Full documentation: &lt;a href=&quot;https://nikiforovall.blog/pi-otel/&quot;&gt;nikiforovall.blog/pi-otel/&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;Pi’s minimalism is a strength. pi-otel adds one thing: a timeline view of what the agent actually did, in a format that integrates with your existing observability stack. One trace tree per turn, three signals, any OTLP backend.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🙌 I hope you found it helpful. If you have any questions, please feel free to reach out. If you’d like to support my work, a star on GitHub would be greatly appreciated! 🙏&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/pi-otel&quot;&gt;pi-otel on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.blog/pi-otel/&quot;&gt;pi-otel docs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://pi.dev&quot;&gt;pi.dev&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://aspire.dev/dashboard/standalone/&quot;&gt;.NET Aspire standalone dashboard&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://opentelemetry.io/docs/specs/semconv/gen-ai/&quot;&gt;OpenTelemetry GenAI semantic conventions&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/pi-otel/tree/main/samples/lgtm&quot;&gt;Grafana LGTM sample&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/productivity/2026/05/16/pi-otel.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/productivity/2026/05/16/pi-otel.html</guid>
			</item>
		
			<item>
				<title>pi-kanban: A Workspace for the Pi Coding Agent</title>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-kanban&lt;/code&gt; is a read-only observability dashboard for the &lt;a href=&quot;https://pi.dev&quot;&gt;pi coding agent&lt;/a&gt;. It watches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.pi/agent/sessions&lt;/code&gt; and renders sessions, todos, messages, and subagents in real time. Install with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi install npm:pi-kanban&lt;/code&gt;, then run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/kanban start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code&lt;/strong&gt;: &lt;a href=&quot;https://github.com/NikiforovAll/pi-kanban&quot;&gt;github.com/NikiforovAll/pi-kanban&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-agent-observability-matters&quot;&gt;Why agent observability matters&lt;/h2&gt;

&lt;p&gt;Working with a coding agent is not the same as writing code yourself. The agent makes decisions, calls tools, spawns subagents, and produces artifacts faster than you can read. Without a way to see what’s happening, you end up babysitting a terminal or trusting the output blindly.&lt;/p&gt;

&lt;p&gt;A proper observability surface changes a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Reduced cognitive load.&lt;/strong&gt; You glance, not read. Status, progress, and what the agent is doing right now are visible without scrolling logs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Better alignment.&lt;/strong&gt; Plan, todos, and messages sit side by side, so course-correction is cheap. You catch drift early instead of after the fact.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A workspace, not a viewer.&lt;/strong&gt; Pinned sessions, pinned messages, linked docs, markdown previews. The work lives in the dashboard, not just a window into it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part I care about most. pi-kanban is read-only against pi’s session state, but the workspace around it is not.&lt;/p&gt;

&lt;h2 id=&quot;what-it-looks-like&quot;&gt;What it looks like&lt;/h2&gt;

&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;center&gt;
    &lt;video src=&quot;https://github.com/user-attachments/assets/4b41e568-1d3d-4f7d-9ebb-dd3f1c2db955&quot; width=&quot;90%&quot; controls=&quot;controls&quot; /&gt;
&lt;/center&gt;

&lt;hr /&gt;

&lt;p&gt;Three panes:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Left&lt;/strong&gt; — sessions grouped by project, with progress, age, and pin state.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Center&lt;/strong&gt; — kanban board (Pending / In Progress / Completed) for the selected session.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Right&lt;/strong&gt; — Session Log: messages, tool calls, subagent activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A bottom strip shows recent subagent runs across all sessions.&lt;/p&gt;

&lt;h2 id=&quot;feature-coverage&quot;&gt;Feature coverage&lt;/h2&gt;

&lt;h3 id=&quot;sessions-and-projects&quot;&gt;Sessions and projects&lt;/h3&gt;

&lt;p&gt;pi-kanban reads &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.pi/agent/sessions/&amp;lt;encoded-cwd&amp;gt;/&amp;lt;timestamp&amp;gt;_&amp;lt;id&amp;gt;.jsonl&lt;/code&gt; and groups sessions by working directory. No daemon, no database — just file watching.&lt;/p&gt;

&lt;h3 id=&quot;pinning-sessions&quot;&gt;Pinning sessions&lt;/h3&gt;

&lt;p&gt;Long-running work doesn’t fit one session. Pinning lifts a session out of the auto-sort so it stays at the top. It’s how you keep the thing you’re actually working on in front of you while exploratory sessions come and go.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/kanban pin &amp;lt;session-id&amp;gt;
/kanban sticky-pin &amp;lt;session-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;pinning-messages-and-linking-documents&quot;&gt;Pinning messages and linking documents&lt;/h3&gt;

&lt;p&gt;Inside a session, you can pin individual messages. Useful for the assistant message that contains the answer, or the user message that defined the task. Linked documents and the scratchpad let you attach context that isn’t part of the conversation but should travel with it.&lt;/p&gt;

&lt;p&gt;This is what turns the dashboard into a workspace. The things you care about stay anchored even as the session log scrolls past them.&lt;/p&gt;

&lt;h3 id=&quot;subagents&quot;&gt;Subagents&lt;/h3&gt;

&lt;p&gt;When &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-subagents&lt;/code&gt; spawns a child session, pi-kanban nests it under the parent.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-subagent.png&quot; alt=&quot;Subagent view&quot; width=&quot;80%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;session-info&quot;&gt;Session info&lt;/h3&gt;

&lt;p&gt;The info modal shows model, token usage, cache hit rate, cost, duration, and paths. The things you check before deciding whether a session is worth resuming.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-info.png&quot; alt=&quot;Session info modal&quot; width=&quot;80%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;storage-manager&quot;&gt;Storage manager&lt;/h3&gt;

&lt;p&gt;Lists sessions, scratchpads, and linked docs with size accounting. Used to clean orphaned docs and unlink stale references.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-storage-explorer.png&quot; alt=&quot;Storage manager&quot; width=&quot;80%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;follow-last-message&quot;&gt;Follow last message&lt;/h3&gt;

&lt;p&gt;Pop the latest assistant message into a floating window.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-follow-last-message.png&quot; alt=&quot;Follow last message&quot; width=&quot;80%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;themes&quot;&gt;Themes&lt;/h3&gt;

&lt;p&gt;Four built-in themes (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-light&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi-dark&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kanban-default&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kanban-dark-default&lt;/code&gt;) and user-defined themes via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.pi/agent/kanban/settings.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-kanban-poster-light.png&quot; alt=&quot;Light theme&quot; width=&quot;80%&quot; style=&quot;display:block;margin:0 auto&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;slash-commands&quot;&gt;Slash commands&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Command&lt;/th&gt;
      &lt;th&gt;What it does&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban start&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Start the local server (port 3460) in the background&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban stop&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Stop the running server&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban open&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Open the dashboard in the default browser&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban app&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Open in a standalone PWA window (if installed)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban pin &amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Pin a session to the top of the sidebar&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban sticky-pin &amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Pin and keep across restarts&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban preview &amp;lt;path&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Render a markdown file in the dashboard preview pane&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;/kanban link &amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Links files to a session&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pi &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;npm:pi-kanban
&lt;span class=&quot;c&quot;&gt;# inside pi:&lt;/span&gt;
/kanban start
/kanban open
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here is a live demo of pi-kanban running on synthetic data:&lt;/p&gt;

&lt;center&gt;
    &lt;iframe src=&quot;https://nikiforovall.blog/pi-kanban&quot; width=&quot;100%&quot; height=&quot;600&quot; frameborder=&quot;0&quot; loading=&quot;lazy&quot; allowfullscreen=&quot;&quot;&gt;&lt;/iframe&gt;
&lt;/center&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;If you use pi and want a dashboard that turns sessions into a workspace, give pi-kanban a try. Questions and feedback welcome on GitHub.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🙌 I hope you found it helpful. If you have any questions, please feel free to reach out. If you’d like to support my work, a star on GitHub would be greatly appreciated! 🙏&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/pi-kanban&quot;&gt;pi-kanban on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://pi.dev&quot;&gt;pi.dev&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/pi-kanban/blob/main/docs/user-guide.md&quot;&gt;User guide&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/pi-kanban/blob/main/docs/theming.md&quot;&gt;Theming&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sat, 09 May 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/ai/productivity/2026/05/09/pi-kanban.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/ai/productivity/2026/05/09/pi-kanban.html</guid>
			</item>
		
			<item>
				<title>Hangfire as an MCP Operations Pane for AI Agents</title>
				<description>&lt;h2 id=&quot;tldr&quot;&gt;TL;DR&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Nall.Hangfire.Mcp&lt;/code&gt; is an in-process library. It exposes your Hangfire jobs at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt; as MCP tools, and ships a built-in maintenance toolset and a few prompts on the side. Call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddHangfireMcp()&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MapHangfireMcp(&quot;/mcp&quot;)&lt;/code&gt; inside the same ASP.NET Core host that already runs Hangfire, and the schemas, descriptions, auth hook, and maintenance tools come with it.&lt;/p&gt;

&lt;p&gt;📖 &lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href=&quot;https://nikiforovall.github.io/hangfire-mcp-dotnet/&quot;&gt;https://nikiforovall.github.io/hangfire-mcp-dotnet/&lt;/a&gt;&lt;/p&gt;

&lt;table style=&quot;width:100%; border-collapse:collapse;&quot;&gt;
  &lt;thead&gt;
    &lt;tr style=&quot;border-bottom:2px solid #ddd;&quot;&gt;
      &lt;th style=&quot;text-align:left; padding:8px;&quot;&gt;Package&lt;/th&gt;
      &lt;th style=&quot;text-align:left; padding:8px;&quot;&gt;Version&lt;/th&gt;
      &lt;th style=&quot;text-align:left; padding:8px;&quot;&gt;Source&lt;/th&gt;
      &lt;th style=&quot;text-align:left; padding:8px;&quot;&gt;Description&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr style=&quot;border-bottom:1px solid #eee;&quot;&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;&lt;code&gt;Nall.Hangfire.Mcp&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;
        &lt;a href=&quot;https://nuget.org/packages/Nall.Hangfire.Mcp&quot;&gt;
          &lt;img src=&quot;https://img.shields.io/nuget/v/Nall.Hangfire.Mcp.svg&quot; alt=&quot;Nuget&quot; /&gt;
        &lt;/a&gt;
      &lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;
        &lt;a href=&quot;https://github.com/NikiforovAll/hangfire-mcp-dotnet&quot;&gt;hangfire-mcp-dotnet&lt;/a&gt;
      &lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;In-process MCP server for Hangfire jobs and queue maintenance.&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;&lt;code&gt;Nall.Hangfire.Mcp.Generator&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;
        &lt;a href=&quot;https://nuget.org/packages/Nall.Hangfire.Mcp.Generator&quot;&gt;
          &lt;img src=&quot;https://img.shields.io/nuget/v/Nall.Hangfire.Mcp.Generator.svg&quot; alt=&quot;Nuget&quot; /&gt;
        &lt;/a&gt;
      &lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;
        &lt;a href=&quot;https://github.com/NikiforovAll/hangfire-mcp-dotnet&quot;&gt;hangfire-mcp-dotnet&lt;/a&gt;
      &lt;/td&gt;
      &lt;td style=&quot;padding:8px;&quot;&gt;Roslyn source generator for compile-time job discovery.&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-why-an-operations-pane&quot;&gt;1. Why an Operations Pane&lt;/h2&gt;

&lt;p&gt;Hangfire’s dashboard is a fine UI for humans. It’s a poor surface for agents. Two specific things are missing.&lt;/p&gt;

&lt;p&gt;1️⃣ The first is parameterized job invocation. The dashboard is built around retrying and inspecting jobs that already exist. It doesn’t give you a clean way to say “run a job for me with parameters” from chat or a copilot.&lt;/p&gt;

&lt;p&gt;2️⃣ The second is operational workflows. “Show me the current backlog.” “List failures from the last hour grouped by exception type.” “Requeue the timeouts, but only the ones older than five minutes.” Most teams end up writing these as one-off scripts.&lt;/p&gt;

&lt;p&gt;MCP is a good fit for both. It standardizes how a client discovers tools, inspects schemas, and invokes them. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Nall.Hangfire.Mcp&lt;/code&gt; puts two kinds of tools on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt;: job tools, one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Run_&amp;lt;jobId&amp;gt;&lt;/code&gt; per discovered Hangfire job; and maintenance tools (statistics, queue listing, get/delete/requeue with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dryRun&lt;/code&gt;), plus three orchestration prompts (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hangfire_health_check&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hangfire_triage_failures&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hangfire_discover&lt;/code&gt;).&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;💡 That mix is what I’m calling an &lt;em&gt;Operations Pane&lt;/em&gt;. Hangfire is the system of record. MCP is the discovery and invocation channel. The operator on the other end is whatever you want it to be: Claude, Copilot, MCP Inspector, your own client.&lt;/p&gt;

&lt;div class=&quot;mermaid&quot;&gt;
flowchart LR
  agent[&quot;AI Agent&quot;] --&amp;gt;|MCP| mcp[/&quot;/mcp endpoint&quot;/]
  mcp --&amp;gt; dispatch{Tool kind?}
  dispatch --&amp;gt;|Run_jobId| sched[HangfireDynamicScheduler]
  dispatch --&amp;gt;|hangfire_*| maint[MaintenanceDispatcher]
  sched --&amp;gt; client[IBackgroundJobClient]
  maint --&amp;gt; storage[(Hangfire JobStorage)]
  client --&amp;gt; storage
  storage --&amp;gt; worker[Hangfire Server&lt;br /&gt;executes job]
&lt;/div&gt;

&lt;h3 id=&quot;aspire-bonus-&quot;&gt;Aspire bonus 🎁&lt;/h3&gt;

&lt;p&gt;Aspire 13.x knows about MCP, so the AppHost wiring is short. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.WithMcpServer(&quot;/mcp&quot;)&lt;/code&gt; declares an MCP endpoint on a project resource, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddMcpInspector(...)&lt;/code&gt; from &lt;a href=&quot;https://github.com/CommunityToolkit/Aspire&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CommunityToolkit.Aspire.Hosting.McpInspector&lt;/code&gt;&lt;/a&gt; deploys the official &lt;a href=&quot;https://github.com/modelcontextprotocol/inspector&quot;&gt;MCP Inspector&lt;/a&gt; as a companion service pointed at your server.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;web&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AddProject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Projects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Web&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;server&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;WithMcpServer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/mcp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddMcpInspector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;inspector&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;WithMcpServer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;web&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two lines of AppHost code, and the Inspector boots up alongside your app pointed at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt;. External clients (Claude Desktop, VS Code Copilot, an SDK) get the same endpoint without any tool authoring on your side.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-getting-started&quot;&gt;2. Getting started&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;dotnet add package Nall.Hangfire.Mcp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The minimal wire-up on top of an existing Hangfire host is two lines:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;builder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddHangfireMcp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapHangfireMcp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/mcp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every recurring job in Hangfire’s storage now shows up as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Run_&amp;lt;jobId&amp;gt;&lt;/code&gt; MCP tool, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hangfire_*&lt;/code&gt; maintenance tools and prompts are wired up. To pick up &lt;em&gt;one-shot&lt;/em&gt; enqueue sites too, opt into the source generator with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;o.Sources = JobDiscoverySources.All&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;21-how-a-job-gets-discovered&quot;&gt;2.1 How a job gets discovered&lt;/h3&gt;

&lt;p&gt;There are two discovery sources, controlled by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HangfireMcpOptions.Sources&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RecurringStorage&lt;/code&gt; (the default) reads &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RecurringJobDto.Job&lt;/code&gt; entries from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JobStorage&lt;/code&gt; at startup. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StaticManifest&lt;/code&gt; runs at compile time: a Roslyn source generator walks &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddOrUpdate&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Enqueue&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; call sites and emits a manifest the runtime consumes — that’s how one-shot enqueues that never live in recurring storage end up in the catalog.&lt;/p&gt;

&lt;p&gt;Both feed a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JobCatalog&lt;/code&gt;, deduped by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(DeclaringType, MethodInfo)&lt;/code&gt;. Schemas are generated once per method and respect both C# defaults and nullable annotations.&lt;/p&gt;

&lt;div class=&quot;mermaid&quot;&gt;
flowchart LR
  subgraph compile [Compile time]
    src[AddOrUpdate / Enqueue /&lt;br /&gt;Schedule call sites] --&amp;gt;|Roslyn| gen[Source generator]
    gen --&amp;gt; manifest[Static manifest]
  end
  subgraph runtime [Runtime]
    rec[(JobStorage&lt;br /&gt;recurring)] --&amp;gt; catalog
    manifest --&amp;gt; catalog[JobCatalog]
    catalog --&amp;gt;|reflection| schema[JSON Schema&lt;br /&gt;per method]
    schema --&amp;gt; tools[&quot;MCP tools&lt;br /&gt;Run_jobId&quot;]
  end
&lt;/div&gt;

&lt;h3 id=&quot;22-decorating-jobs-with-description&quot;&gt;2.2 Decorating jobs with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Description]&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Tool selection in agents lives or dies by description quality. Generic auto-generated descriptions and an agent will pick the wrong tool or fill arguments badly. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Nall.Hangfire.Mcp&lt;/code&gt; honors the standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;System.ComponentModel.DescriptionAttribute&lt;/code&gt; at both the method and parameter level — no custom attribute types.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;IReportJob&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Generate the annual financial report and persist it.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;GenerateAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Calendar year of the report (e.g. 2026).&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;year&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Output format. Supported: pdf, html, csv.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;format&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Optional cutoff timestamp.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;DateTimeOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;since&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The method &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Description]&lt;/code&gt; becomes the tool’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;description&lt;/code&gt;. Each parameter &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Description]&lt;/code&gt; becomes the JSON Schema &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;description&lt;/code&gt; for that property. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format&lt;/code&gt; is optional because of the C# default; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;since&lt;/code&gt; is optional because of the nullable annotation. Only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;year&lt;/code&gt; ends up in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;required&lt;/code&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-demo-mcp-inspector&quot;&gt;3. Demo: MCP Inspector&lt;/h2&gt;

&lt;p&gt;End-to-end, with the Aspire AppHost running, the loop is: list the tools on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt;, fill in parameters in MCP Inspector, hit &lt;strong&gt;Run Tool&lt;/strong&gt;, and see the same enqueue land in the Hangfire dashboard.&lt;/p&gt;

&lt;figure style=&quot;margin:1.5rem 0; text-align:center;&quot;&gt;
  &lt;img src=&quot;/assets/2026/hangfire-mcp-operations-pane/inspector-invoke.png&quot; alt=&quot;MCP Inspector — invoking a job with parameters&quot; style=&quot;width:75%; border:1px solid #eee; border-radius:4px;&quot; /&gt;
  &lt;figcaption style=&quot;font-size:0.9em; margin-top:6px;&quot;&gt;1. Invoking with parameters — JSON Schema rendered as a form.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure style=&quot;margin:1.5rem 0; text-align:center;&quot;&gt;
  &lt;img src=&quot;/assets/2026/hangfire-mcp-operations-pane/inspector-result.png&quot; alt=&quot;MCP Inspector — tool result with Hangfire job ID&quot; style=&quot;width:75%; border:1px solid #eee; border-radius:4px;&quot; /&gt;
  &lt;figcaption style=&quot;font-size:0.9em; margin-top:6px;&quot;&gt;2. Result in the Inspector — Hangfire job ID and state.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure style=&quot;margin:1.5rem 0; text-align:center;&quot;&gt;
  &lt;img src=&quot;/assets/2026/hangfire-mcp-operations-pane/hangfire-dashboard.png&quot; alt=&quot;Hangfire dashboard — succeeded job from MCP&quot; style=&quot;width:75%; border:1px solid #eee; border-radius:4px;&quot; /&gt;
  &lt;figcaption style=&quot;font-size:0.9em; margin-top:6px;&quot;&gt;3. Same job in the Hangfire dashboard — exact args, real queue.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The point: the agent didn’t need to know anything about Hangfire. It saw a tool, filled in the schema, and the queue did its usual thing.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-authentication-and-authorization&quot;&gt;4. Authentication and authorization&lt;/h2&gt;

&lt;p&gt;The library is auth-agnostic on purpose. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MapHangfireMcp(&quot;/mcp&quot;)&lt;/code&gt; returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IEndpointConventionBuilder&lt;/code&gt;, so you layer ASP.NET Core auth on top with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.RequireAuthorization(...)&lt;/code&gt; like any other endpoint.&lt;/p&gt;

&lt;p&gt;The full picture for a tool call looks like this:&lt;/p&gt;

&lt;div class=&quot;mermaid&quot;&gt;
sequenceDiagram
  autonumber
  participant Agent as AI Agent
  participant MCP as /mcp
  participant Auth as ASP.NET Core auth
  participant Pipe as JobInvocationPipeline
  participant HF as Hangfire
  Agent-&amp;gt;&amp;gt;MCP: CallTool (no token)
  MCP--&amp;gt;&amp;gt;Agent: 401 + WWW-Authenticate (RFC 9728)
  Agent-&amp;gt;&amp;gt;Agent: discover OAuth metadata, obtain JWT
  Agent-&amp;gt;&amp;gt;MCP: CallTool (Bearer JWT)
  MCP-&amp;gt;&amp;gt;Auth: validate token, RequireAuthorization
  Auth-&amp;gt;&amp;gt;Pipe: invoke tool
  Pipe-&amp;gt;&amp;gt;Pipe: evaluate [Authorize] on job
  alt allowed
    Pipe-&amp;gt;&amp;gt;HF: Enqueue job
    HF--&amp;gt;&amp;gt;Agent: tool result
  else denied
    Pipe--&amp;gt;&amp;gt;Agent: MCP error (Forbidden)
  end
&lt;/div&gt;

&lt;h3 id=&quot;authentication&quot;&gt;Authentication&lt;/h3&gt;

&lt;p&gt;The sample uses Keycloak as the OAuth 2.0 / OIDC authorization server, JWT bearer for token validation, and the MCP-spec &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;McpAuthenticationHandler&lt;/code&gt; to emit RFC 9728 challenges. That last bit matters: it lets MCP clients self-discover the protected-resource metadata at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/.well-known/oauth-protected-resource/mcp&lt;/code&gt;, which is how you avoid hardcoding auth server URLs into clients.&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapHangfireMcp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/mcp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddAuthenticationSchemes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;McpAuthenticationDefaults&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AuthenticationScheme&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The full setup (Keycloak realm import, JWT + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddMcp(...)&lt;/code&gt; resource metadata, the container-vs-host issuer URL caveat that bit me twice) is in the repo at &lt;a href=&quot;https://github.com/NikiforovAll/hangfire-mcp-dotnet/blob/main/docs/authentication.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/authentication.md&lt;/code&gt;&lt;/a&gt;. Other ASP.NET Core schemes work the same way: Entra ID, Auth0, custom JWT — nothing about the library cares which one you pick.&lt;/p&gt;

&lt;h3 id=&quot;per-job-authorization&quot;&gt;Per-job authorization&lt;/h3&gt;

&lt;p&gt;Endpoint auth is necessary but not enough. Once a client is past the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt; gate it can list every tool, and you usually want different jobs guarded by different policies. So &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Nall.Hangfire.Mcp&lt;/code&gt; honors the standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Authorize]&lt;/code&gt; attribute on job methods — opt in with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddJobAuthorization()&lt;/code&gt;, then decorate:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Authorize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Policy&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;jobs:run&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)]&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;GenerateAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;year&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;format&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;DateTimeOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;since&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Under the hood, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddJobAuthorization()&lt;/code&gt; registers an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AuthorizeAttributeFilter&lt;/code&gt; into the job invocation pipeline. When a tool call hits &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HangfireMcpHandlers&lt;/code&gt;, the pipeline collects &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Authorize]&lt;/code&gt; attributes from the method and its declaring type, evaluates them against the current &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ClaimsPrincipal&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IAuthorizationService&lt;/code&gt;, and short-circuits to an MCP error if any check fails. No Hangfire shim, no custom attribute. It’s the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Authorize]&lt;/code&gt; you’d put on a controller action.&lt;/p&gt;

&lt;p&gt;That keeps the threat model boring, which is what you want. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mcp&lt;/code&gt; endpoint is gated by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RequireAuthorization()&lt;/code&gt;. Per-job access is gated by ordinary ASP.NET Core policies. Catalog visibility and execution rights are separate concerns: an agent that lacks the right claims still sees jobs in the catalog it can’t run, and the call returns Forbidden when it tries.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Nall.Hangfire.Mcp&lt;/code&gt; collapses the “expose Hangfire to an MCP client” problem into a couple of lines of host code. Discovery comes from two sources (recurring storage at runtime, a Roslyn source generator at compile time). Schemas come from method signatures, with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Description]&lt;/code&gt; doing the heavy lifting. Auth and authorization reuse the standard ASP.NET Core stack: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RequireAuthorization()&lt;/code&gt; on the endpoint, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Authorize(Policy = ...)]&lt;/code&gt; on the job.&lt;/p&gt;

&lt;p&gt;Inside an Aspire AppHost, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.WithMcpServer&lt;/code&gt; plus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddMcpInspector&lt;/code&gt; is most of the integration work. What you end up with is what I keep calling an Operations Pane: one surface where agents can run jobs &lt;em&gt;and&lt;/em&gt; operate the queue, and where the security model is the same one you already use for the rest of the app.&lt;/p&gt;

&lt;h3 id=&quot;reference&quot;&gt;Reference&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Repo: &lt;a href=&quot;https://github.com/NikiforovAll/hangfire-mcp-dotnet&quot;&gt;https://github.com/NikiforovAll/hangfire-mcp-dotnet&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;NuGet: &lt;a href=&quot;https://www.nuget.org/packages/Nall.Hangfire.Mcp&quot;&gt;https://www.nuget.org/packages/Nall.Hangfire.Mcp&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Documentation: &lt;a href=&quot;https://nikiforovall.github.io/hangfire-mcp-dotnet/&quot;&gt;https://nikiforovall.github.io/hangfire-mcp-dotnet/&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://modelcontextprotocol.io/&quot;&gt;Model Context Protocol&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/inspector&quot;&gt;MCP Inspector&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/CommunityToolkit/Aspire&quot;&gt;CommunityToolkit.Aspire McpInspector hosting integration&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/rfc9728/&quot;&gt;RFC 9728, OAuth 2.0 Protected Resource Metadata&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/dotnet/ai/2026/05/02/hangfire-mcp-operations-pane.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/dotnet/ai/2026/05/02/hangfire-mcp-operations-pane.html</guid>
			</item>
		
			<item>
				<title>User-Managed Access (UMA) 2.0 Resource Sharing with Keycloak and .NET</title>
				<description>&lt;h2 id=&quot;tldr&quot;&gt;TL;DR&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;UMA 2.0 lets resource owners control access to their stuff. Think Google Drive sharing, but wired into your authorization server.&lt;/li&gt;
  &lt;li&gt;Keycloak.AuthServices now supports the challenge-response flow (automatic ticket-for-RPT exchange) and async approval (request, wait for owner, get in).&lt;/li&gt;
  &lt;li&gt;A Blazor Server + Minimal API sample shows the full flow. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UmaTokenHandler&lt;/code&gt; handles the UMA dance behind the scenes.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IKeycloakProtectionClient&lt;/code&gt; covers the permission ticket lifecycle: create, list, approve, deny.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Source code&lt;/strong&gt;: &lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/UmaResourceSharing&quot;&gt;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/UmaResourceSharing&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-is-uma&quot;&gt;What is UMA?&lt;/h2&gt;

&lt;p&gt;Most authorization systems work the same way: the resource server decides who gets access based on roles, claims, or policies that an admin defined. But what if the &lt;em&gt;resource owner&lt;/em&gt;, not the admin, should make that call?&lt;/p&gt;

&lt;p&gt;User-Managed Access (UMA) is an OAuth 2.0 extension that flips this. Alice can grant or revoke access to her resources for bob, without an admin involved, and without needing to be online when bob asks.&lt;/p&gt;

&lt;p&gt;You’ve seen this pattern before:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Sharing a Google Doc with specific people&lt;/li&gt;
  &lt;li&gt;Granting a colleague access to a private GitHub repo&lt;/li&gt;
  &lt;li&gt;Approving a permission request in a cloud console&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What makes UMA different from regular OAuth is the asynchronous approval step. Bob requests access, alice reviews it later, and only then does bob get in.&lt;/p&gt;

&lt;h3 id=&quot;key-concepts&quot;&gt;Key concepts&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Concept&lt;/th&gt;
      &lt;th&gt;Description&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Resource Owner&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;The user who owns the protected resource&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Requesting Party&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A user who wants access to someone else&apos;s resource&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Permission Ticket&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A one-time challenge token representing an access request&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;RPT (Requesting Party Token)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;An access token enriched with specific resource permissions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Protection API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Keycloak API for managing resources, permissions, and tickets&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;demo&quot;&gt;Demo&lt;/h2&gt;

&lt;p&gt;Here’s what this looks like in the Blazor sample.&lt;/p&gt;

&lt;h3 id=&quot;alice-resource-owner-instant-access&quot;&gt;alice (resource owner), instant access&lt;/h3&gt;

&lt;p&gt;Alice owns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared-document&lt;/code&gt;, so authorization succeeds right away:&lt;/p&gt;

&lt;p align=&quot;center&quot;&gt;
  &lt;video src=&quot;/assets/2026/keycloak-uma/uma-resource-sharing-login.mp4&quot; autoplay=&quot;&quot; muted=&quot;&quot; loop=&quot;&quot; playsinline=&quot;&quot; controls=&quot;&quot; aria-label=&quot;Login and Resource Owner Access&quot;&gt;&lt;/video&gt;
&lt;/p&gt;

&lt;h3 id=&quot;bob-requests-access-alice-approves&quot;&gt;bob requests access, alice approves&lt;/h3&gt;

&lt;p&gt;Bob has no permission. He submits a request, alice approves it, then bob retries and gets in:&lt;/p&gt;

&lt;p align=&quot;center&quot;&gt;
  &lt;video src=&quot;/assets/2026/keycloak-uma/uma-resource-sharing-approve.mp4&quot; autoplay=&quot;&quot; muted=&quot;&quot; loop=&quot;&quot; playsinline=&quot;&quot; controls=&quot;&quot; aria-label=&quot;Request Access and Owner Approval&quot;&gt;&lt;/video&gt;
&lt;/p&gt;

&lt;h3 id=&quot;bob-no-permission-access-denied&quot;&gt;bob (no permission), access denied&lt;/h3&gt;

&lt;p&gt;Without approval, bob gets a clear “Access Denied”:&lt;/p&gt;

&lt;p align=&quot;center&quot;&gt;
  &lt;video src=&quot;/assets/2026/keycloak-uma/uma-resource-sharing-denied.mp4&quot; autoplay=&quot;&quot; muted=&quot;&quot; loop=&quot;&quot; playsinline=&quot;&quot; controls=&quot;&quot; aria-label=&quot;Access Denied&quot;&gt;&lt;/video&gt;
&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-the-uma-flow-works&quot;&gt;How the UMA flow works&lt;/h2&gt;

&lt;p&gt;Two flows, working together.&lt;/p&gt;

&lt;h3 id=&quot;challenge-response-instant-access&quot;&gt;Challenge-response (instant access)&lt;/h3&gt;

&lt;p&gt;When a user already has permission, the client handles the exchange automatically:&lt;/p&gt;

&lt;div class=&quot;mermaid&quot;&gt;
sequenceDiagram
    participant User as User (Browser)
    participant Client as Client App
    participant RS as Resource Server
    participant KC as Keycloak

    User-&amp;gt;&amp;gt;Client: Access protected resource
    Client-&amp;gt;&amp;gt;RS: GET /resource (Bearer token)
    RS-&amp;gt;&amp;gt;KC: Evaluate permissions
    KC--&amp;gt;&amp;gt;RS: 403 (no permission yet)
    RS-&amp;gt;&amp;gt;KC: Create permission ticket
    KC--&amp;gt;&amp;gt;RS: ticket=&quot;abc123...&quot;
    RS--&amp;gt;&amp;gt;Client: 401 WWW-Authenticate: UMA ticket=&quot;abc123...&quot;
    Client-&amp;gt;&amp;gt;KC: Exchange ticket for RPT
    KC--&amp;gt;&amp;gt;Client: RPT (with permissions)
    Client-&amp;gt;&amp;gt;RS: GET /resource (Bearer RPT)
    RS--&amp;gt;&amp;gt;Client: 200 OK — resource content
&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UmaTokenHandler&lt;/code&gt; handles steps 6-9. Your code just makes HTTP calls as usual.&lt;/p&gt;

&lt;h3 id=&quot;async-approval-request-approve-access&quot;&gt;Async approval (request, approve, access)&lt;/h3&gt;

&lt;p&gt;When a user has no permission, they submit a request. The resource owner reviews it later:&lt;/p&gt;

&lt;div class=&quot;mermaid&quot;&gt;
sequenceDiagram
    participant Bob as Bob
    participant App as Application
    participant KC as Keycloak
    participant Alice as Alice (Owner)

    Bob-&amp;gt;&amp;gt;App: Request access to resource
    App-&amp;gt;&amp;gt;KC: Create permission ticket (granted=false)
    KC--&amp;gt;&amp;gt;App: 201 Created
    App--&amp;gt;&amp;gt;Bob: &quot;Request submitted&quot;

    Note over Alice: Alice logs in later

    Alice-&amp;gt;&amp;gt;App: View pending requests
    App-&amp;gt;&amp;gt;KC: GET permission tickets (granted=false)
    KC--&amp;gt;&amp;gt;App: Pending tickets list
    Alice-&amp;gt;&amp;gt;App: Approve bob&apos;s request
    App-&amp;gt;&amp;gt;KC: Update ticket (granted=true)

    Note over Bob: Bob retries

    Bob-&amp;gt;&amp;gt;App: Access resource
    App-&amp;gt;&amp;gt;KC: Evaluate permissions
    KC--&amp;gt;&amp;gt;App: Permitted
    App--&amp;gt;&amp;gt;Bob: Resource content
&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;

&lt;p&gt;Two projects: a Blazor Server client talks to a Minimal API resource server. Keycloak sits in the middle, handling authorization decisions and permission tickets.&lt;/p&gt;

&lt;div class=&quot;mermaid&quot; style=&quot;text-align: center;&quot;&gt;
graph LR
    subgraph Aspire AppHost
        KC[Keycloak&lt;br /&gt;Authorization Server]
        RS[Resource Server&lt;br /&gt;Minimal API]
        CA[Client App&lt;br /&gt;Blazor Server]
    end

    CA --&amp;gt;|HTTP + Bearer Token| RS
    RS --&amp;gt;|Authorization Check| KC
    RS --&amp;gt;|Permission Ticket Creation| KC
    CA --&amp;gt;|Ticket → RPT Exchange| KC
&lt;/div&gt;

&lt;h3 id=&quot;resource-server&quot;&gt;Resource server&lt;/h3&gt;

&lt;p&gt;The resource server protects endpoints with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RequireProtectedResource()&lt;/code&gt; and returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WWW-Authenticate: UMA&lt;/code&gt; challenges on authorization failure via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddUmaPermissionTicketChallenge()&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddAuthentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JwtBearerDefaults&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AuthenticationScheme&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakWebApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Audience&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;uma-resource-server&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RequireHttpsMetadata&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Authorization + UMA challenge handler&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddUmaPermissionTicketChallenge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddAuthorizationServer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddStandardResilienceHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Protection API for ticket management&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakProtectionHttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddClientCredentialsTokenHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokenClientName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Protected endpoints&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/documents/{name}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DocumentResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$&quot;Content of &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireProtectedResource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;shared-document&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;read&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/documents/{name}/details&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DocumentResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$&quot;Detailed content of &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireProtectedResource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;shared-document&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;write&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It also exposes permission ticket management endpoints:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Bob submits an access request&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/permissions/request&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ticket&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PermissionTicket&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Resource&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resourceId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Requester&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ScopeName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;read&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Granted&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;protectionClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;CreatePermissionTicketWithResponseAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;realm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ticket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Alice lists pending requests&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/permissions/pending&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;protectionClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetPermissionTicketsAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;realm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GetPermissionTicketsRequestParameters&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Granted&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReturnNames&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Alice approves&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapPut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/permissions/{id}/approve&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;protectionClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;UpdatePermissionTicketAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;realm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PermissionTicket&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Granted&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;client-app-blazor-server&quot;&gt;Client app (Blazor Server)&lt;/h3&gt;

&lt;p&gt;The Blazor client registers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UmaTokenHandler&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Authorization.Uma&lt;/code&gt;. It’s a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DelegatingHandler&lt;/code&gt; that intercepts 401+UMA responses and handles the ticket exchange:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// UMA ticket exchange client&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakUmaTicketExchangeHttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// HTTP client with UMA handling&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddHttpClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ResourceServer&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GetRequiredService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IConfiguration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;baseUrl&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;services:resource-server:http:0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BaseAddress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;baseUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddUmaTokenHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;What &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UmaTokenHandler&lt;/code&gt; does:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Attaches the user’s access token to outgoing requests&lt;/li&gt;
  &lt;li&gt;On &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;401&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WWW-Authenticate: UMA&lt;/code&gt;, extracts the permission ticket&lt;/li&gt;
  &lt;li&gt;Exchanges the ticket for an RPT at Keycloak’s token endpoint&lt;/li&gt;
  &lt;li&gt;Retries the original request with the RPT&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the Blazor component side, none of this is visible. You just make HTTP calls:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Http&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;$&quot;/documents/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;documentName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;document&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;keycloak-setup&quot;&gt;Keycloak setup&lt;/h2&gt;

&lt;p&gt;UMA needs a few things configured in Keycloak:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;A confidential client with Authorization Services enabled (the resource server)&lt;/li&gt;
  &lt;li&gt;A resource with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ownerManagedAccess: true&lt;/code&gt; and scopes like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;read&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;write&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;An owner policy that grants the resource owner access&lt;/li&gt;
  &lt;li&gt;An OIDC client for user login, with an audience mapper pointing to the resource server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/UmaResourceSharing&quot;&gt;sample&lt;/a&gt; ships with pre-configured Keycloak realm exports, so you can just run it:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;dotnet run &lt;span class=&quot;nt&quot;&gt;--project&lt;/span&gt; samples/UmaResourceSharing/AppHost
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Aspire opens the dashboard automatically with Keycloak, the resource server, and the Blazor app.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;Role-based access control doesn’t cover the case where users share resources with each other. UMA does.&lt;/p&gt;

&lt;p&gt;With Keycloak.AuthServices you get &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UmaTokenHandler&lt;/code&gt; for the challenge-response dance, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddUmaPermissionTicketChallenge()&lt;/code&gt; for resource servers, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IKeycloakProtectionClient&lt;/code&gt; for managing permission tickets. The sample runs with a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotnet run&lt;/code&gt; via Aspire.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.github.io/keycloak-authorization-services-dotnet/protection-api/uma&quot;&gt;Documentation: UMA 2.0&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.github.io/keycloak-authorization-services-dotnet/examples/uma-resource-sharing&quot;&gt;Documentation: UMA Examples&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/UmaResourceSharing&quot;&gt;Sample source code&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.keycloak.org/docs/latest/authorization_services/index.html&quot;&gt;Keycloak Authorization Services Guide&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-grant-2.0.html&quot;&gt;UMA 2.0 Specification&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;Join us on Discord: &lt;a href=&quot;https://discord.gg/jdYFw2xq&quot;&gt;&lt;img src=&quot;https://img.shields.io/discord/1236946465318768670?color=blue&amp;amp;label=Chat%20on%20Discord&quot; alt=&quot;Discord&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
				<pubDate>Wed, 15 Apr 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/dotnet/keycloak/2026/04/15/keycloak-uma-resource-sharing.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/dotnet/keycloak/2026/04/15/keycloak-uma-resource-sharing.html</guid>
			</item>
		
			<item>
				<title>Claude Code Hub: Kanban, Marketplace, Cost, and Memory in One Place</title>
				<description>&lt;h2 id=&quot;too-many-tabs&quot;&gt;Too many tabs&lt;/h2&gt;

&lt;p&gt;I built a bunch of small tools around Claude Code over the past few months. A Kanban board for watching agent work. A marketplace for managing plugins. A cost dashboard. A memory explorer. Each one is its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npx&lt;/code&gt; command and its own browser tab, and at some point I got tired of juggling four terminals and four tabs every time I sat down to work.&lt;/p&gt;

&lt;p&gt;So I wrote a hub that runs them all behind one command.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npx claude-code-hub &lt;span class=&quot;nt&quot;&gt;--open&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One process spawns four servers, opens a browser, done. The tools live in iframes and you switch between them with keyboard shortcuts. No visible chrome, no tabs.&lt;/p&gt;

&lt;h2 id=&quot;whats-inside&quot;&gt;What’s inside&lt;/h2&gt;

&lt;h3 id=&quot;kanban&quot;&gt;Kanban&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026/claude-code-hub/cck.png&quot; alt=&quot;Kanban&quot; /&gt;&lt;/p&gt;

&lt;p&gt;A real-time task board for Claude Code sessions. It shows tasks, agents, and subagent teams as Claude works. State comes from JSONL files written by lightweight hooks (more on that below).&lt;/p&gt;

&lt;h3 id=&quot;marketplace&quot;&gt;Marketplace&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026/claude-code-hub/marketplace.png&quot; alt=&quot;Marketplace&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Browse and install Claude Code plugins across local, user, and project scopes. Basically an app store for skills and MCP servers.&lt;/p&gt;

&lt;h3 id=&quot;cost&quot;&gt;Cost&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026/claude-code-hub/cost.png&quot; alt=&quot;Cost&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Token usage and cost breakdowns. Drill from totals down to projects, then to individual sessions. I check this more often than I’d like to admit.&lt;/p&gt;

&lt;h3 id=&quot;memory&quot;&gt;Memory&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/2026/claude-code-hub/memory.png&quot; alt=&quot;Memory&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Shows all memory sources that feed into Claude Code: CLAUDE.md files, rules, auto memory, import chains. Really helpful when Claude does something unexpected and you want to figure out which instruction is responsible.&lt;/p&gt;

&lt;h2 id=&quot;how-it-actually-works&quot;&gt;How it actually works&lt;/h2&gt;

&lt;p&gt;The hub server spawns each sub-app as a child process on its own port. If a port is busy, the tool picks a random free one. The hub reads stdout to detect the actual port each tool landed on, then serves a shell page that embeds them as iframes.&lt;/p&gt;

&lt;p&gt;Switching is keyboard-only:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Shortcut&lt;/th&gt;
      &lt;th&gt;Action&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Alt+1&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Kanban&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Alt+2&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Marketplace&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Alt+3&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Cost&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Alt+4&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Memory&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+Alt+Arrow&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Next / previous tool&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Tools can also deep-link to each other via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;postMessage&lt;/code&gt;. Click a session in Cost and it can jump you straight to that session’s Kanban board.&lt;/p&gt;

&lt;p&gt;You can install it as a PWA for a standalone window with no browser UI. That’s the way I use it, honestly. It feels like its own app.&lt;/p&gt;

&lt;p&gt;The whole thing is vanilla JS and Express. No bundler, no build step, works on Node 18+.&lt;/p&gt;

&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;

&lt;h3 id=&quot;1-install-hooks-once&quot;&gt;1. Install hooks (once)&lt;/h3&gt;

&lt;p&gt;For the full Kanban experience, you need to install hooks that log agent activity:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npx claude-code-kanban &lt;span class=&quot;nt&quot;&gt;--install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;These are small shell scripts that append to JSONL files. Negligible overhead. Without them you still get the task board, just no live agent indicators.&lt;/p&gt;

&lt;h3 id=&quot;2-launch&quot;&gt;2. Launch&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npx claude-code-hub &lt;span class=&quot;nt&quot;&gt;--open&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;3-use-claude-code-normally&quot;&gt;3. Use Claude Code normally&lt;/h3&gt;

&lt;p&gt;The hub updates live. Switch tools with the keyboard while Claude works.&lt;/p&gt;

&lt;h2 id=&quot;standalone-mode&quot;&gt;Standalone mode&lt;/h2&gt;

&lt;p&gt;Each tool also works on its own if you only want one:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;npx claude-code-kanban          &lt;span class=&quot;c&quot;&gt;# task board&lt;/span&gt;
npx claude-code-marketplace     &lt;span class=&quot;c&quot;&gt;# plugin manager&lt;/span&gt;
npx claude-code-cost            &lt;span class=&quot;c&quot;&gt;# cost dashboard&lt;/span&gt;
npx claude-code-memory-explorer &lt;span class=&quot;c&quot;&gt;# memory explorer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Claude Code doesn’t need to be running either. You can browse past sessions and costs anytime; live updates just appear when Claude starts.&lt;/p&gt;

&lt;h2 id=&quot;links&quot;&gt;Links&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/NikiforovAll/claude-code-hub&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.npmjs.com/package/claude-code-hub&quot;&gt;npm&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nikiforovall.blog/claude-code-hub/&quot;&gt;Landing page&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
				<pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/productivity/ai/2026/04/08/claude-code-hub.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/productivity/ai/2026/04/08/claude-code-hub.html</guid>
			</item>
		
			<item>
				<title>What&apos;s new in Keycloak.AuthServices v2.9.0</title>
				<description>&lt;h2 id=&quot;whats-new&quot;&gt;What’s new&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet&quot;&gt;Keycloak.AuthServices&lt;/a&gt; just got a round of updates. Keycloak itself has changed a lot between versions 24 and 26 – lightweight access tokens, organizations, RFC 8414 metadata – and the .NET library needed to catch up.&lt;/p&gt;

&lt;p&gt;This post walks through the three biggest additions.&lt;/p&gt;

&lt;hr /&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Package&lt;/th&gt;
      &lt;th&gt;Version&lt;/th&gt;
      &lt;th&gt;Description&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Authentication&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nuget.org/packages/Keycloak.AuthServices.Authentication&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Keycloak.AuthServices.Authentication.svg&quot; alt=&quot;Nuget&quot; /&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Authentication for API and Web Apps&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Authorization&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nuget.org/packages/Keycloak.AuthServices.Authorization&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Keycloak.AuthServices.Authorization.svg&quot; alt=&quot;Nuget&quot; /&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Authorization Services + Authorization Server integration&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Authorization.TokenIntrospection&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nuget.org/packages/Keycloak.AuthServices.Authorization.TokenIntrospection&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Keycloak.AuthServices.Authorization.TokenIntrospection.svg&quot; alt=&quot;Nuget&quot; /&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Token introspection for lightweight access tokens&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Sdk&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nuget.org/packages/Keycloak.AuthServices.Sdk&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Keycloak.AuthServices.Sdk.svg&quot; alt=&quot;Nuget&quot; /&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Admin API and Protection API&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Keycloak.AuthServices.Sdk.Kiota&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nuget.org/packages/Keycloak.AuthServices.Sdk.Kiota&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Keycloak.AuthServices.Sdk.Kiota.svg&quot; alt=&quot;Nuget&quot; /&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;Admin API based on OpenAPI (Kiota-generated)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;organization-authorization&quot;&gt;Organization authorization&lt;/h2&gt;

&lt;p&gt;Keycloak 24 introduced &lt;a href=&quot;https://www.keycloak.org/docs/latest/server_admin/#_managing_organizations&quot;&gt;organizations&lt;/a&gt; as a first-class concept – and for multi-tenant applications, this changes things.&lt;/p&gt;

&lt;p&gt;Before organizations, multi-tenancy in Keycloak usually meant one realm per tenant (operational headache) or custom attributes and groups hacked together to represent “who belongs where”. Organizations give you a built-in abstraction: users belong to organizations, organizations have their own identity providers, and membership shows up directly in the token.&lt;/p&gt;

&lt;p&gt;For a SaaS app, this means you can stop reinventing tenant isolation. Keycloak manages the “which user belongs to which tenant” question, and the token carries that answer. Your API just needs to enforce it.&lt;/p&gt;

&lt;p&gt;The library picks that up and gives you authorization policies that work with it.&lt;/p&gt;

&lt;p&gt;Keycloak produces two claim formats depending on the Organization Membership Mapper configuration – a simple string array or a rich JSON map with IDs and attributes. Both are handled transparently.&lt;/p&gt;

&lt;p&gt;To include organization membership in tokens, request the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;organization:*&lt;/code&gt; scope. The plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;organization&lt;/code&gt; scope alone does &lt;strong&gt;not&lt;/strong&gt; include the claim.&lt;/p&gt;

&lt;h3 id=&quot;require-any-organization-membership&quot;&gt;Require any organization membership&lt;/h3&gt;

&lt;p&gt;The simplest check – the user must belong to at least one organization:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/orgs&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;You belong to an org&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireOrganizationMembership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;require-a-specific-organization&quot;&gt;Require a specific organization&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/acme/settings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Acme settings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireOrganizationMembership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;acme-corp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;resolve-the-organization-from-route-or-header&quot;&gt;Resolve the organization from route or header&lt;/h3&gt;

&lt;p&gt;Sometimes the organization comes from the request itself. The library ships with built-in resolvers for routes, headers, and query parameters:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/orgs/{orgId}/projects&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;orgId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$&quot;Projects for &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;orgId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireOrganizationMembership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{orgId}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;MapGet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/tenant/projects&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Tenant projects&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RequireOrganizationMembership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RouteHandlerBuilder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HeaderParameterResolver&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{X-Organization}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;policy-based-approach&quot;&gt;Policy-based approach&lt;/h3&gt;

&lt;p&gt;If you prefer named policies:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddAuthorizationBuilder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddPolicy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;AcmeOnly&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;policy&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireOrganizationMembership&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;acme-corp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
              &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;RequireRealmRoles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;imperative-authorization&quot;&gt;Imperative authorization&lt;/h3&gt;

&lt;p&gt;For more dynamic scenarios:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;authorizationService&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AuthorizeAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;OrganizationRequirement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;orgId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OrganizationRequirementHandler&lt;/code&gt; reads the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;organization&lt;/code&gt; claim, parses the JSON structure Keycloak emits, and checks membership. You can configure which claim type to look at if your setup uses a non-default claim name.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;token-introspection-for-lightweight-access-tokens&quot;&gt;Token introspection for lightweight access tokens&lt;/h2&gt;

&lt;p&gt;This one bit me in production. Keycloak 24+ supports lightweight access tokens – valid signed JWTs, but intentionally stripped of business claims like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resource_access&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;realm_access&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;preferred_username&lt;/code&gt;. The idea is to keep tokens small.&lt;/p&gt;

&lt;p&gt;The problem: role-based authorization silently fails. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;KeycloakRolesClaimsTransformation&lt;/code&gt; looks for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resource_access&lt;/code&gt;, finds nothing, maps no roles. Every role check returns 403. No errors, no warnings. Just 403s.&lt;/p&gt;

&lt;p&gt;The fix is &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc7662&quot;&gt;token introspection&lt;/a&gt;. You send the token back to Keycloak’s introspection endpoint and get the full claim set.&lt;/p&gt;

&lt;h3 id=&quot;installation&quot;&gt;Installation&lt;/h3&gt;

&lt;p&gt;Token introspection ships as a separate package:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;dotnet add package Keycloak.AuthServices.Authorization.TokenIntrospection
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;enabling-introspection&quot;&gt;Enabling introspection&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakWebApiAuthentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakAuthorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;EnableRolesMapping&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RolesClaimTransformationSource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;All&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakTokenIntrospection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s it. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddKeycloakTokenIntrospection&lt;/code&gt; call registers a claims transformation that:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Checks if expected claims (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resource_access&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;realm_access&lt;/code&gt;) are already present&lt;/li&gt;
  &lt;li&gt;If missing, calls the introspection endpoint with client credentials&lt;/li&gt;
  &lt;li&gt;Merges the the full claim set into the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ClaimsPrincipal&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Caches results per token to avoid redundant calls&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The existing role mapping transformation runs after introspection, so everything works transparently.&lt;/p&gt;

&lt;h3 id=&quot;configuration&quot;&gt;Configuration&lt;/h3&gt;

&lt;p&gt;Via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;appsettings.json&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;Keycloak&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;realm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;my-realm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;auth-server-url&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://keycloak.example.com/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;resource&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;my-api&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;credentials&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;secret&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;my-client-secret&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The introspection client reuses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;KeycloakInstallationOptions&lt;/code&gt;, so if you already have Keycloak configured, you likely don’t need to add anything.&lt;/p&gt;

&lt;p&gt;Cache duration defaults to 60 seconds and is configurable:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakTokenIntrospection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BindKeycloakOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CacheDuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TimeSpan&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FromSeconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;120&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;when-to-use-this&quot;&gt;When to use this&lt;/h3&gt;

&lt;p&gt;You need this if:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Your Keycloak admin enabled lightweight access tokens&lt;/li&gt;
  &lt;li&gt;You’re using KC 26+ admin clients (they use lightweight tokens by default)&lt;/li&gt;
  &lt;li&gt;You see unexplained 403s after upgrading Keycloak&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The alternative is configuring Keycloak protocol mappers with the “Add to lightweight access token” flag, which avoids the introspection round-trip entirely. Pick your trade-off.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;rfc-8414-metadata-discovery&quot;&gt;RFC 8414 metadata discovery&lt;/h2&gt;

&lt;p&gt;Since Keycloak 26.4, the &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8414&quot;&gt;RFC 8414&lt;/a&gt; metadata endpoint is available at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/.well-known/oauth-authorization-server&lt;/code&gt;, alongside the traditional OIDC discovery at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/.well-known/openid-configuration&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why does this matter? If you’re building a pure OAuth 2.0 resource server – no ID tokens, no OIDC – then OIDC discovery is technically the wrong endpoint. More practically, Keycloak 26.4 added MCP (Model Context Protocol) authorization server support, which uses RFC 8414 discovery.&lt;/p&gt;

&lt;h3 id=&quot;using-rfc-8414-metadata&quot;&gt;Using RFC 8414 metadata&lt;/h3&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;AddKeycloakWebApiAuthentication&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;configuration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;BindKeycloakOptions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MetadataAddress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;KeycloakConstants&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OAuthAuthorizationServerMetadataPath&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MetadataAddress&lt;/code&gt; property overrides the default OIDC discovery path. The constant &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OAuthAuthorizationServerMetadataPath&lt;/code&gt; resolves to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;.well-known/oauth-authorization-server&quot;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you don’t set it, nothing changes – the library defaults to OIDC discovery as before.&lt;/p&gt;

&lt;h3 id=&quot;when-to-use-this-1&quot;&gt;When to use this&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Machine-to-machine flows (client credentials, no ID tokens)&lt;/li&gt;
  &lt;li&gt;OAuth 2.0-only clients that expect RFC 8414 metadata&lt;/li&gt;
  &lt;li&gt;MCP authorization server scenarios with Keycloak 26.4+&lt;/li&gt;
  &lt;li&gt;Protocol correctness for pure resource servers&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;also-in-this-release&quot;&gt;Also in this release&lt;/h2&gt;

&lt;p&gt;A few more things that shipped in this release:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Extensible policy builder&lt;/strong&gt; – &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IProtectedResourcePolicyBuilder&lt;/code&gt; lets you customize how protected resource policies are constructed&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pluggable parameter resolution&lt;/strong&gt; – custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IParameterResolver&lt;/code&gt; implementations for resolving resource names from routes, headers, query strings, or your own sources&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Configurable organization claim type&lt;/strong&gt; – if your Keycloak setup uses a non-standard claim name for organization data&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Kiota SDK updated&lt;/strong&gt; to Keycloak 26.5.6 OpenAPI spec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full changelog: &lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/releases&quot;&gt;GitHub Releases&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Documentation: &lt;a href=&quot;https://nikiforovall.github.io/keycloak-authorization-services-dotnet/&quot;&gt;nikiforovall.github.io/keycloak-authorization-services-dotnet&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;ai-skills-for-claude-code&quot;&gt;AI skills for Claude Code&lt;/h2&gt;

&lt;p&gt;This release also ships a &lt;a href=&quot;https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/.claude-plugin&quot;&gt;Claude Code plugin&lt;/a&gt; with two AI skills that help you work with Keycloak directly from your terminal.&lt;/p&gt;

&lt;h3 id=&quot;keycloak-auth-services&quot;&gt;keycloak-auth-services&lt;/h3&gt;

&lt;p&gt;An implementation guide that knows the library’s API surface – authentication setup, authorization patterns, resource protection, Admin SDK, Protection API, and configuration options. Ask it how to set up JWT Bearer auth or configure protected resources and it’ll give you working code using the current API.&lt;/p&gt;

&lt;h3 id=&quot;keycloak-administration&quot;&gt;keycloak-administration&lt;/h3&gt;

&lt;p&gt;A Keycloak server administration guide covering realm management, client configuration, authentication flows, RBAC, user federation, security hardening, and troubleshooting. Useful when you need to configure the Keycloak side of things – setting up clients, mappers, organizations, or debugging token issues.&lt;/p&gt;

&lt;hr /&gt;

&lt;blockquote&gt;
  &lt;p&gt;Join us on Discord: &lt;a href=&quot;https://discord.gg/jdYFw2xq&quot;&gt;&lt;img src=&quot;https://img.shields.io/discord/1236946465318768670?color=blue&amp;amp;label=Chat%20on%20Discord&quot; alt=&quot;Discord&quot; /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
				<pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate>
				<link>https://nikiforovall.blog/dotnet/keycloak/2026/03/27/keycloak-authservices-2026.html</link>
				<guid isPermaLink="true">https://nikiforovall.blog/dotnet/keycloak/2026/03/27/keycloak-authservices-2026.html</guid>
			</item>
		
	</channel>
</rss>
