An interactive chat UI for Townie, a Claude Managed Agent wired to the logged-in user's Val Town account over MCP. Each thread is one stateful agent session; the agent can read and edit your vals, run your SQLite, ship HTTP endpoints, and produce files — all acting as you, with your permissions. Hono (backend) + React (frontend), Val Town SQLite for storage.
This is a remix of
templates/claude-managed-agents-ui
that adds per-user Val Town MCP access.
The Managed Agents API keeps MCP auth out of the (shared, versioned) agent config and supplies it per-session via a vault:
- The agent (
backend/agent.ts) declares the Val Town MCP server by URL only —mcp_servers: [{ type: "url", name: "val_town", url: "https://api.val.town/v3/mcp" }]— plus anmcp_toolsetset toalways_allowso Townie's tools run without approval prompts. The system prompt is Val Town's official Townie prompt (fetched fromval.town/townie/system-prompt) plus an addendum explaining that this Townie runs as a Managed Agent and the user's vals live behind the MCP. - The login (
index.ts) usesoauthMiddlewarewith the scopes Townie needs —val_rw,project_rw,sqlite_rw,blob_rw,user_r,telemetry_rw,offline_access— so the user's access token can actually do the work. - Per user (
backend/vault.ts) we keep one Anthropic vault holding astatic_bearercredential whose token is that user's Val Town access token, bound to the Val Town MCP URL. It's created on first use and the token is rotated to the current one on every turn (std/oauth keeps the cookie token fresh), so the credential never goes stale. - Per session (
backend/agent.ts) we attach the user's vault viavault_idswhen creating the thread's session (vaults can only be set at session-create time). Anthropic injects the bearer onto every outbound MCP call — the token never touches the agent's sandbox.
The result: when you tell Townie "add a dark-mode toggle to my homepage val", it
calls list_vals / read_file / update_file / fetch_val_endpoint on the Val
Town MCP as you, and edits land in your account.
- A thread = one session. The agent + environment are created once
(lazily, cached in a
configrow); sessions pin to an agent version. Each thread makes one session (bound to its owner's vault) and reuses it. - On a new user message the server refreshes the user's vault token, ensures the
session, then opens the event stream, sends the message, and writes each
event to SQLite —
agent.message→ text,agent.tool_use/agent.mcp_tool_use→ tool call,session.error→ error — returning when the session goes idle. - The frontend polls
GET /api/threads/:id/messages?after=<lastId>~1×/s and renders new rows. No SSE to the browser. - Files Townie creates under
/mnt/session/outputs/are surfaced after each turn:backend/files.tslists them (files.list({ scope_id })), downloads them, stashes the bytes instd/blob, and writes an image/file row served atGET /api/files/:id.
Set ANTHROPIC_API_KEY (from https://platform.claude.com/settings/keys).
Everything else — agent, environment, per-user vaults — is created on first use.
Tweak the model or system prompt in backend/agent.ts (the default is
claude-sonnet-4-6; swap to claude-opus-4-8 for the strongest Townie). To
re-create the agent after changing its prompt/tools, clear the agent_id row in
the config table.
index.ts— Hono server + API routes + OAuth scopesbackend/agent.ts— Managed Agent: create-once config (Val Town MCP + Townie prompt), per-thread vault-bound session, per-turn streamingbackend/vault.ts— per-user Anthropic vault holding the Val Town MCP bearer credential (created once, token rotated each turn)backend/auth.ts— resolve the Val Town user + access token from the sessionbackend/db.ts— SQLite schema + helpers (threads,messages,config,user_vaults)backend/files.ts— surface session output files: Files API →std/blob→ image/file rowsbackend/og.ts— Open Graph card atGET /og.pngfrontend/— React chat UI
- MCP tools run as the user, with
always_allow. Townie edits your real vals without a confirmation step — same trust model as the official Townie. To add an approval gate, set themcp_toolsetpermission policy toalways_askand handleagent.tool_use(evaluated_permission === "ask") with auser.tool_confirmationround-trip. - Token lifetime. The vaulted token is the user's Val Town OAuth access token; it's refreshed every turn from the live session cookie. A session left idle past token expiry recovers on the next turn.
- Stream reconnection / long turns. The turn request stays open for the whole
agent turn; if it dies before
session.status_idle, reopening the stream and replaying from the thread'slast_cursorwould resume it. Long turns can hit Val Town request limits — tied to the same reconnection work.