Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Dockerfile.claude-code
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
#
# Overlay image: the base Instatic app + the Claude Code CLI, so the
# `claude-code` AI provider works inside the container. Kept separate from the
# main Dockerfile so the base image tracks upstream cleanly.
#
# Build:
# docker build -t instatic:local . # base (main Dockerfile)
# docker build -f Dockerfile.claude-code -t instatic-cc:local . # this overlay
#
# Auth is NOT baked in. At run time provide the machine's Claude SUBSCRIPTION
# via CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) — see
# docs/features/claude-code-provider.md and compose.claude-code.yml.

ARG BASE_IMAGE=instatic:local
FROM ${BASE_IMAGE}

USER root
# The base `oven/bun` image has no curl; add it just to fetch the installer,
# then drop the apt lists. The installer drops a self-contained binary under
# /root/.local; copy it to a world-readable path and discard the rest.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://claude.ai/install.sh | bash \
&& mkdir -p /opt/claude \
&& cp "$(readlink -f /root/.local/bin/claude)" /opt/claude/claude \
&& chmod 0755 /opt/claude/claude \
&& rm -rf /root/.local \
&& /opt/claude/claude --version

ENV INSTATIC_CLAUDE_BIN=/opt/claude/claude
USER bun
32 changes: 32 additions & 0 deletions compose.claude-code.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# compose.claude-code.yml
# Overlay that enables the Claude Code (subscription) AI provider in the
# container. Layer it LAST, on top of the prod (and sqlite) compose files.
#
# Usage:
# docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.claude-code.yml up -d
#
# Prerequisites (one-time):
# 1. Build the image WITH the Claude Code CLI:
# docker build -t instatic:local .
# docker build -f Dockerfile.claude-code -t instatic-cc:local .
# then set in .env: INSTATIC_IMAGE=instatic-cc:local
#
# 2. Mint a long-lived SUBSCRIPTION token on the host (interactive, one-time):
# claude setup-token
# then put it in .env: CLAUDE_CODE_OAUTH_TOKEN=<the token>
# This bills your flat-rate Claude subscription — it is NOT an API key and
# spends no metered API credits. Revoke/rotate it anytime by re-running
# `claude setup-token`.
#
# Note: no ~/.claude mount is needed — the token is the only auth input.

services:
app:
environment:
# Subscription auth for the spawned `claude` CLI. The driver never strips
# this var (unlike ANTHROPIC_API_KEY), so the CLI authenticates on the
# subscription. Empty → the Claude Code provider will report it can't sign
# in; every other provider keeps working.
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
# Baked into the overlay image already; set explicitly for clarity.
INSTATIC_CLAUDE_BIN: /opt/claude/claude
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ Three categories, three voices:
| [features/spotlight.md](features/spotlight.md) | Cmd+K command palette |
| [features/agent.md](features/agent.md) | AI agent integration and provider-agnostic runtime |
| [features/mcp-connectors.md](features/mcp-connectors.md) | Instatic as an MCP server — external AI clients drive the CMS over MCP |
| [features/claude-code-provider.md](features/claude-code-provider.md) | Running the agent on a Claude subscription by spawning the `claude` CLI |
| [features/templates.md](features/templates.md) | Entry templates + dynamic bindings + token interpolation |
| [features/loops.md](features/loops.md) | `base.loop` + loop entity sources |
| [features/cms-native-forms.md](features/cms-native-forms.md) | Visual form primitives and secure public submissions |
Expand Down
9 changes: 7 additions & 2 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ In the Site editor, the agent reads the current page snapshot, plans a sequence

In the Content workspace, the agent works against content collections and entries. It reads collection schemas and document state server-side, then mutates the live content editor through a browser bridge so the open draft, Tiptap body editor, and sidebar selection stay authoritative.

The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All drivers share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`.
The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint). Every HTTP driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. Those drivers share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`.

One driver is deliberately not an HTTP driver: [Claude Code](claude-code-provider.md) spawns the local `claude` CLI and runs the agent on the operator's Claude subscription rather than a metered API key. It brings its own tool loop (the CLI's), reaches the CMS over Instatic's own MCP server instead of the shared tool loop, and satisfies the SDK ban by construction — it imports nothing and spawns a binary.

---

Expand Down Expand Up @@ -72,7 +74,10 @@ server/ai/
│ ├── openai.ts — OpenAI driver: direct POST /v1/responses (no SDK)
│ ├── openrouter.ts — OpenRouter driver: direct POST /v1/responses (shared Responses path; live /models; native cost)
│ ├── ollama.ts — Ollama driver: POST /v1/chat/completions via shared chatCompletions adapter; live /api/tags catalogue
│ └── openaiCompatible.ts — Custom Provider driver: any /v1/chat/completions endpoint; live GET /v1/models catalogue
│ ├── openaiCompatible.ts — Custom Provider driver: any /v1/chat/completions endpoint; live GET /v1/models catalogue
│ ├── claudeCode.ts — Claude Code driver (NOT an HTTP driver): spawns the `claude` CLI on the machine subscription; argv/env + session mode
│ ├── claudeCodeProcess.ts — one CLI spawn: stderr drain, idle watchdog, session-mode fallback, terminal outcome classification
│ └── claudeCodeEvents.ts — CLI stream-json → AiStreamEvent translation (pure; unit-tested from recorded output)
└── runtime/
├── runner.ts — runChat(): drives a driver, emits stream events
├── persister.ts — ConversationsPersister: messages + usage to DB; writes contextTokens snapshot
Expand Down
166 changes: 166 additions & 0 deletions docs/features/claude-code-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Claude Code provider (subscription-backed)

The **Claude Code** provider runs the CMS agent on the machine's logged-in
Claude **subscription** (Pro/Max) instead of a metered Anthropic API key. You
type in the normal AI panel; behind the scenes the driver spawns the headless
`claude` CLI, points it at Instatic's own MCP server, and streams its output
back into the chat — so the agent is real Claude Code (its tool loop, its
harness), editing the live workspace you have open.

This is the inverse of connecting an *external* Claude Code to Instatic over
[MCP connectors](./mcp-connectors.md): here Instatic drives Claude Code, but the
editing still flows through the same live editor bridge.

## Why a CLI subprocess (and not the Agent SDK)

The AI drivers ban every provider **SDK** (`@anthropic-ai/claude-agent-sdk`
included — gated by `ai-driver-isolation.test.ts`) because drivers normally talk
raw provider REST. The Claude Code driver is the deliberate exception: it never
imports an SDK — it **spawns the `claude` binary**, which the gate (import-only)
permits. The subscription cost model only exists in the CLI/subscription auth
path; the Agent SDK bills API credits, so it would defeat the purpose anyway.

## How it works

```
AI panel (Site workspace, open in the browser)
│ POST /admin/api/ai/chat/site
▼
server/ai/handlers/chat.ts → resolveDriver('claude-code')
▼
server/ai/drivers/claudeCode.ts ── Bun.spawn('claude', …)
│ • ANTHROPIC_API_KEY stripped from the child env → subscription auth
│ • ENABLE_TOOL_SEARCH=false in the child env → present every MCP tool up
│ front. See "Tool visibility" below; without this the turn has no
│ usable tools at all.
│ • --tools "" → strip ALL built-in tools (Bash/Read/Edit/…)
│ • --allowedTools mcp__instatic → pre-approve ONLY this server's tools,
│ so the ~50 mcp__instatic__* tools are directly callable
│ • --system-prompt <site agent prompt>
│ • --mcp-config <instatic> --strict-mcp-config
│ • --dangerously-skip-permissions (built-ins stripped; ensures MCP
│ tool calls never block on a permission prompt)
│ • --session-id/--resume keyed on the conversation (deterministic UUID)
▼
claude CLI ──MCP (bearer)──▶ /_instatic/mcp
│ │ server/ai/mcp/connectors/internalConnector.ts
│ │ mints a per-user connector granted the
│ │ caller's OWN capabilities (never more)
▼ ▼
stream-json stdout executeAiTool / live editor bridge
│ ▼
translated → AiStreamEvent the OPEN Site/Content workspace (your live edits)
```

- **Auth:** default (non-`--bare`) CLI mode reads the OAuth/subscription
credential. `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` are removed from the
child env so the CLI can't fall back to metered billing. `--bare` is never
used (it forces API-key auth).
- **Credential-less:** the provider needs no secret. To satisfy the existing
`ai_creds_apikey_shape_check` DB constraint without a migration, the credential
row is stored as `authMode: 'baseUrl'` with the inert sentinel
`claude-code://local` (never dialed). See `CLAUDE_CODE_SENTINEL_BASE_URL`.
- **Tool surface:** the CLI sees only the `mcp__instatic__*` tools (the same
set as `server/ai/mcp/registry.ts`), capability-filtered by the internal
connector. Browser-execution tools route to the owner's open workspace via the
MCP editor bridge; headless reads run in-process. Edits stay drafts until an
explicit `site_publish`.
- **Pricing / context:** billed to the subscription, so `resolveCostUsd` returns
`0` for `claude-code`; usage is reported in the native Anthropic shape, so it
normalises like `anthropic` in `contextTokens.ts`.

### Tool visibility (why `ENABLE_TOOL_SEARCH=false`)

`--tools ""` strips every *built-in* tool, which is what keeps Bash/Edit/WebFetch
out of the CMS chat. The CLI's **tool search** (env `ENABLE_TOOL_SEARCH`, default
`auto`) defers a large MCP toolset behind the `ToolSearch` built-in — and that is
a built-in, so `--tools ""` strips it too. With ~50 tools on this server the
default crosses the deferral threshold, and the combination leaves the model with
**zero callable tools**.

A model with no tools does not error: it narrates a tool call in prose
("`get_context` … calling that now") and ends the turn `subtype: "success"`. That
is why the failure looked like the agent simply stopping a few seconds in.

Disabling tool search presents all ~50 tools directly, which is both correct and
faster (~5s to the first tool call, versus ~13-19s through search round trips).

Two independent guards keep this from regressing silently:

- The driver reads the tool list out of the CLI's `system`/`init` event and
aborts the turn with a clear error if no `mcp__instatic__*` tool is present,
rather than letting the model bluff. `mcp_servers[].status` distinguishes
"tool search hid them" from "the MCP server failed to start".
- Each turn logs `[ai/claude-code] session <id> started — N CMS tool(s)`. `N` is
the number to check first when the agent misbehaves.

### Other failure modes made visible

A subprocess can go quiet in ways an HTTP driver cannot, and all of them look
identical from the composer. Each is terminal and named:

- **Wedged child.** stderr is drained concurrently — an unread pipe blocks the
writer once full (~64KB) — and an idle watchdog
(`INSTATIC_CLAUDE_IDLE_TIMEOUT_MS`, default 180s) kills a CLI that stops
emitting, SIGTERM then SIGKILL.
- **Session mismatch.** `--resume` of a session that isn't on disk, and
`--session-id` of one that already exists, both exit non-zero. Each retries
once as the other mode; safe because the failed attempt emitted nothing.
- **Rate limits.** A `rate_limit_event` that isn't `allowed` is surfaced with its
reset time instead of looking like a stall.
- **Format drift.** Unreadable output lines are counted and logged, so a future
CLI changing its stream-json shape shows up as a warning rather than silence.

## Setup (bare-metal / dev)

1. Sign in with `claude` on the server host (subscription, not an API key).
2. Ensure the `claude` binary is on the server's PATH, or set
`INSTATIC_CLAUDE_BIN` to its absolute path.
3. In **AI → Providers**, add the **Claude Code (your subscription)** credential
(no key needed), then pick it as the Site/Content default or per conversation.

## Setup (Docker / prod)

The base image has no `claude` binary. Build the overlay image and provide a
subscription token as an env var — no home-directory mount required.

1. **Build the image with the CLI** (`Dockerfile.claude-code` overlays the base):
```sh
docker build -t instatic:local .
docker build -f Dockerfile.claude-code -t instatic-cc:local .
```
The overlay installs the self-contained `claude` binary to `/opt/claude/claude`
and bakes `INSTATIC_CLAUDE_BIN` pointing at it.

2. **Mint a subscription token on the host** (interactive, one-time):
```sh
claude setup-token # requires an active Claude subscription
```
This is a long-lived Claude Code OAuth token — it bills the flat-rate
subscription, spends no metered API credits, and is revocable by re-running
the command.

3. **Wire it in `.env`** next to the compose files:
```sh
INSTATIC_IMAGE=instatic-cc:local
CLAUDE_CODE_OAUTH_TOKEN=<token from step 2>
```

4. **Run with the overlay compose file** (layer it last):
```sh
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.claude-code.yml up -d
```

Inside the container the driver spawns `claude`, which authenticates via
`CLAUDE_CODE_OAUTH_TOKEN` (the driver strips `ANTHROPIC_API_KEY`/
`ANTHROPIC_AUTH_TOKEN` but preserves this var) and connects to the server's own
MCP endpoint on `127.0.0.1:$PORT` — same-container loopback. Then add the
**Claude Code (your subscription)** credential in **AI → Providers** as above.

## Limitations

- Single-operator / personal use. Driving a subscription programmatically to
back an app is a grey area of Anthropic's terms; this is intended for the
operator editing their own site, not multi-tenant serving.
- Subscription rate limits (e.g. Max weekly caps) apply.
- The internal connector token lives in-process and rotates on server restart.
4 changes: 3 additions & 1 deletion server/ai/contextTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export function normalizeContextTokens(
providerId: AiProviderId,
usage: ContextUsageTokens,
): number {
if (providerId === 'anthropic') {
// Claude Code reports usage in the native Anthropic shape (input_tokens
// excludes the cache buckets), so it normalises the same way.
if (providerId === 'anthropic' || providerId === 'claude-code') {
return usage.promptTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
}
return usage.promptTokens
Expand Down
Loading