Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`--vanilla`.** Serves every request as a plain model call instead of a full
Claude Code agent: no built-in tools, no MCP servers, no `CLAUDE.md`, and the
caller's system message replaces the agent prompt instead of extending it.
For callers that run their own tool loop (opencode being the motivating
case) and would otherwise end up fighting a second agent underneath ccal.
Off by default; existing agentic behaviour is unchanged. `GET /v1/models`
drops `"tools"` from `supported_parameters` in this mode.

## [1.4.0] - 2026-08-06

### Added
Expand Down
49 changes: 41 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ endpoint.

| File | Responsibility |
|---|---|
| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. |
| `cli.ts` | The `ccal` entry point (has the `#!/usr/bin/env node` shebang). Parses the `serve` subcommand flags (`--port`, `--host`, `--cwd`, `--permission-mode`, `--allow-origin`, `--vanilla`, `--help`), probes for the `claude` binary at startup, and starts the Hono server via `@hono/node-server`. |
| `server.ts` | Builds the Hono app. Mounts `GET /health`, `GET /v1/models`, and `POST /v1/chat/completions`; sets up permissive CORS; orchestrates prompt prep → spawn → stream translation for both the streaming (SSE) and non-streaming paths; reaps the child process. |
| `prompt.ts` | Flattens an OpenAI `messages` array into a single stdin text payload plus a combined system prompt. System messages are joined; user/assistant turns become a labeled `User:` / `Assistant:` transcript (a lone message is sent verbatim). |
| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. |
| `claude.ts` | Spawns the `claude` CLI subprocess with the exact agentic (or, in vanilla mode, plain-model) flags, writes the prompt to stdin, ensures the binary's directory is on `PATH`, and exposes stdout as an async iterator of decoded lines. |
| `stream.ts` | Translates the CLI's `stream-json` events into user-visible text: text deltas pass through; tool calls are accumulated and rendered as a compact one-line italic note. Holds the per-response `StreamState`. |
| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). |
| `models.ts` | The static model list for `GET /v1/models` — `sonnet`, `opus`, `haiku` — and the default model (`sonnet`). In vanilla mode, drops `"tools"` from each model's `supported_parameters`. |
| `claude-binary.ts` | Resolves the absolute path to the `claude` binary across several strategies (see below), and probes its `--version` at startup. |

## The `claude` CLI contract ccal depends on
Expand All @@ -46,7 +46,8 @@ claude -p \
--verbose \
--model <id> \
--no-session-persistence \
[--append-system-prompt <system>] \
[--append-system-prompt <system>] # agentic mode, system content only
[--tools "" --safe-mode --strict-mcp-config --system-prompt <text>] # vanilla mode only
[--permission-mode <mode>]
```

Expand All @@ -57,12 +58,44 @@ claude -p \
- `--model` takes the alias from the request (`sonnet` / `opus` / `haiku`).
- `--no-session-persistence` keeps each request stateless.
- `--append-system-prompt` is added only when the request has system content, so
the default agent prompt (and tool use) is preserved.
the default agent prompt (and tool use) is preserved. **Agentic mode only.**
- `--permission-mode` is forwarded only for an explicit non-default value.

It runs as a **full Claude Code agent** — tools, MCP servers, and `CLAUDE.md`
load based on `--cwd`. `--bare`, `--strict-mcp-config`, and `--setting-sources`
are deliberately not passed.
It runs as a **full Claude Code agent by default** — tools, MCP servers, and
`CLAUDE.md` load based on `--cwd`. `--bare`, `--strict-mcp-config`, and
`--setting-sources` are deliberately not passed in this mode.

**Vanilla mode** (`--vanilla` on `ccal serve`, `vanilla: true` in `SpawnOptions`)
runs a plain model call instead, for callers (opencode being the motivating one)
that run their own tool loop and would otherwise end up with two agents fighting
over the same turn. Four flags change, each verified against `claude --help`
rather than assumed — the reasoning lives in the comment on the vanilla block in
`spawnClaude()` (`claude.ts`), and is worth reading in full before touching any
of them:

- **`--tools ""`** disables every built-in tool. On its own this does *not* stop
`CLAUDE.md` or MCP servers from loading.
- **`--safe-mode`, not `--bare`.** `--bare` looks like the obvious flag for
killing `CLAUDE.md` auto-discovery, but its own help text says Anthropic auth
becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper` — OAuth and keychain are
never read. Verified: `claude -p --bare` on a subscription-logged-in box fails
with "Not logged in - Please run /login", which would break ccal for exactly
the users it exists for. `--safe-mode` disables `CLAUDE.md`, skills, plugins,
hooks and MCP servers while explicitly leaving auth working normally.
- **`--strict-mcp-config`** is mostly redundant with what `--safe-mode` already
does (verified via the `stream-json` init event's `mcp_servers` field), but is
kept as a second, low-cost guarantee — `--safe-mode`'s own help text carves out
an exception for admin-managed settings, and it's unclear whether that could
include a managed MCP server.
- **`--system-prompt`, not `--append-system-prompt`.** Appending leaves the
agent's default system prompt in place, and even with zero tools available the
model still believes it's an agent with tools — verified: it emitted fake
`<function_calls>...` text instead of a plain answer. `--system-prompt`
replaces the prompt outright. When the request has no system message, ccal
substitutes a small neutral default rather than passing `--system-prompt ""`
or omitting the flag — both were tested and both fall back to the same broken
agent-framing behaviour (and a whitespace-only prompt is rejected outright by
the API).

**Binary resolution order** (`claude-binary.ts`): `CCAL_CLAUDE_PATH` →
scan `PATH` → ask the login shell (`$SHELL -lic 'command -v claude'`) →
Expand Down
51 changes: 48 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of

- `GET /health` → `{ "ok": true }`
- `GET /v1/models` → the OpenAI model list. Three ids are advertised, matching
the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`.
the `claude` CLI's `--model` aliases: `sonnet`, `opus`, `haiku`. When the
server was started with `--vanilla`, `supported_parameters` drops `"tools"`
for every model, since the backing agent has none to use.
- `POST /v1/chat/completions` → OpenAI-compatible chat completions. Supports both
`stream: true` (SSE `data: {choices:[{delta:{content}}]}` frames terminated by
`data: [DONE]`) and the non-streaming case (a single assembled
Expand All @@ -116,7 +118,9 @@ provider with base URL `http://127.0.0.1:8787/v1`, any API key, and pick one of

- `system` messages are combined and passed to claude via
`--append-system-prompt` (appended to the default agent prompt, so tool use
still works).
still works). In [`--vanilla`](#vanilla-mode-no-tools-no-mcp-no-claudemd)
mode they go through `--system-prompt` instead, replacing the prompt rather
than extending it.
- `user` / `assistant` messages are flattened into a single text transcript sent
on stdin. A lone message is sent verbatim; a multi-turn conversation is
rendered as a `User:` / `Assistant:` labeled transcript. This is one prompt in,
Expand All @@ -142,14 +146,55 @@ ccal serve [options]
--permission-mode <m> Permission mode passed to claude (non-default only)
--allow-origin <origin> Let a site on the public internet call this server
(repeatable)
--vanilla Serve plain model calls instead of a full Claude
Code agent (no tools, no MCP servers, no CLAUDE.md)
--help Show this help
```

- `--cwd` controls where claude runs, which determines the tools, MCP servers,
and `CLAUDE.md` it loads.
and `CLAUDE.md` it loads (agentic mode only — `--vanilla` turns all three off
regardless of `--cwd`).
- `CCAL_CLAUDE_PATH` (environment variable) forces a specific `claude` binary
when auto-detection does not find the right one.

### Vanilla mode: no tools, no MCP, no CLAUDE.md

By default ccal runs `claude` as a **full Claude Code agent**: it has its
built-in tools, connects to whatever MCP servers your `claude` config defines,
and loads `CLAUDE.md` from `--cwd`. That is the right shape for most uses of
ccal — a chat UI or SDK that just wants the model to actually do things.

It is the wrong shape when the *caller* is itself an agent with its own tool
loop — for example [opencode](https://opencode.ai). Point opencode at ccal
without `--vanilla` and you get two independent agents fighting over the same
turn: opencode issues tool calls of its own while the `claude` process
underneath is separately reading files, running bash, and picking up whatever
`CLAUDE.md` sits in `--cwd`.

```sh
ccal serve --vanilla
```

turns every request into a plain model call:

- No built-in tools (`--tools ""` on the `claude` invocation).
- No `CLAUDE.md`, skills, plugins, hooks, or MCP servers (`--safe-mode`, plus
`--strict-mcp-config` as a second guarantee against MCP).
- The caller's `system` message **replaces** the agent's default system prompt
(`--system-prompt`) instead of being appended to it — so the model isn't
told it's a coding agent with tools it no longer has. If a request carries
no system message, ccal substitutes a small neutral one rather than leaving
the agent framing in place.

Your own subscription login still works — vanilla mode does *not* use
`--bare`, which would require `ANTHROPIC_API_KEY` and stop reading your
OAuth/keychain login. `GET /v1/models` also drops `"tools"` from
`supported_parameters` in this mode, since the backing model genuinely can't
use any.

Off by default; existing consumers that rely on the agentic behaviour are
unaffected.

### Calling ccal from a website

If the page calling ccal is served over the public internet — a deployed app
Expand Down
69 changes: 68 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,65 @@ Notably, `--bare`, `--strict-mcp-config`, and `--setting-sources` are **not**
passed. ccal runs claude as a full Claude Code agent: tools, MCP servers, and
`CLAUDE.md` all load relative to `--cwd`.

Two practical details:
#### Vanilla mode

When the server was started with `--vanilla` (`AdapterConfig.vanilla` →
`SpawnOptions.vanilla`), `spawnClaude()` builds a different invocation — a plain
model call instead of an agent:

```
claude -p \
--output-format stream-json \
--include-partial-messages \
--verbose \
--model <id> \
--no-session-persistence \
--tools "" \
--safe-mode \
--strict-mcp-config \
--system-prompt <system-or-neutral-default> \
[--permission-mode <mode>]
```

- **`--tools ""`** disables every built-in tool (`""` is the CLI's own syntax
for "no tools"). By itself this does not stop `CLAUDE.md` or MCP servers from
loading — verified: with only `--tools ""`, a `CLAUDE.md` in `--cwd` still
leaked its content into the response.
- **`--safe-mode`** (not `--bare`) is what actually stops `CLAUDE.md`
auto-discovery, along with skills, plugins, and hooks, plus MCP servers.
`--bare` was the first instinct and is wrong here: its help text says
Anthropic auth becomes strictly `ANTHROPIC_API_KEY` / `apiKeyHelper`, and
OAuth/keychain are never read. Verified by running `claude -p --bare` on a
subscription-logged-in machine: it failed with "Not logged in - Please run
/login" even though the identical prompt without `--bare` succeeded. That
would defeat ccal's whole reason for existing — driving your existing
subscription login, no API key. `--safe-mode` gets the CLAUDE.md/MCP
suppression without that cost, while its own help text confirms auth stays
untouched.
- **`--strict-mcp-config`** is largely redundant with what `--safe-mode`
already does — verified by inspecting the `stream-json` init event's
`mcp_servers` field, which comes back empty under `--safe-mode` alone — but
is kept as a second, essentially free guarantee: `--safe-mode`'s own help
text notes that admin-managed (policy) settings still apply, and it's
unclear whether that could include a managed MCP server.
- **`--system-prompt <text>`**, not `--append-system-prompt`. Appending leaves
the default agent system prompt in place, and the model still believes it
has tools even when none are actually wired up — verified: with
`--safe-mode --tools ""` but the prompt still appended, a "list the files"
request came back emitting fake `<function_calls>...` text instead of a
plain-English answer. `--system-prompt` replaces the prompt outright, which
fixes that. When the request carries no system message, ccal does **not**
pass `--system-prompt ""` or omit the flag — both were tested and both fall
back to the same broken agent-framing behaviour as appending (and a
whitespace-only prompt is rejected outright by the underlying API: "system:
text content blocks must contain non-whitespace text"). Instead it
substitutes a small neutral default system prompt.

`GET /v1/models` also reflects vanilla mode: `supported_parameters` drops
`"tools"` for every model, since the backing agent has none in this mode (see
below).

Two practical details (both modes):

- **PATH augmentation.** claude shells out to helper binaries that may live
alongside it, so ccal prepends the binary's own directory to `PATH` for the
Expand Down Expand Up @@ -185,6 +243,15 @@ Returns a static OpenAI-shaped list of exactly three ids — `sonnet`, `opus`,
`object: "model"`, `owned_by: "ccal"`, and a stable `created: 0` so responses are
deterministic. The default model when a request omits `model` is `sonnet`.

`supported_parameters` is catalog metadata about what the backing agent can do
while producing a response, not a literal OpenAI tool-calling contract ccal
implements (it never reads a `tools` field off the request). `listModels()`
takes the server's `vanilla` flag and, when set, filters `"tools"` out of every
model's `supported_parameters` — advertising it would describe a capability
that mode's request path can't actually exercise. `"reasoning"` (extended
thinking) is unaffected by tool availability and stays either way — verified:
thinking tokens still show up in vanilla responses from sonnet/opus.

## CORS and Private Network Access

By default CORS is intentionally permissive (`origin: "*"`) so local browser apps
Expand Down
21 changes: 21 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ You should see `data: {...}` SSE frames followed by `data: [DONE]`.
> chat endpoint needs `claude` installed and logged in; if it can't be found you
> get a `502` with a clear message.

**Vanilla mode:** start the server with `--vanilla` and a request that would
otherwise use a tool should instead get a plain-English answer with no tool
activity:

```sh
node dist/cli.js serve --port 8787 --vanilla
curl http://127.0.0.1:8787/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "sonnet",
"messages": [{ "role": "user", "content": "List the files in the current directory." }]
}'
```

Compare against the same request without `--vanilla`: the agentic server
actually runs a tool and returns real file names (plus a `*↳ Bash: ...*` note);
the vanilla server says it can't do that and asks you to run the command
yourself. That contrast is the whole point of the flag — see [the README
section](../README.md#vanilla-mode-no-tools-no-mcp-no-claudemd) for what
changes and why.

If auto-detection doesn't find your binary, set the override:

```sh
Expand Down
Loading
Loading