Documentation
¶
Overview ¶
Package claudechannel implements the Claude Code channel Platform for Chatwright: an emulated relay server that lets a real `claude` CLI session be driven as the bot-under-test through Anthropic's experimental "claude/channel" MCP contract.
Unlike Telegram or WhatsApp, Claude Code has no bot API of its own to emulate. Instead, a small relay binary (cmd/chatwright-claudechannel) is registered as an MCP stdio server and named on `claude --channels server:<name>`. That relay long-polls this package's Emulator over a private HTTP protocol and forwards inbound user text to the running Claude session as a "notifications/claude/channel" JSON-RPC notification; the session replies by calling the relay's "reply" (and optionally "edit_message") MCP tool, which the relay posts back to the Emulator. See Launch and BuildRelay for starting a real `claude` process against an Emulator, and the package-level "Launch mode" and "Org policy and model choice" notes below for what was empirically verified.
Launch mode ¶
`claude --channels server:<name> ...` starts an interactive session that requires a TTY (it prints a raw-mode UI even with no prompt argument and no piped stdin), so Launch always runs the process under a pseudo-terminal (github.com/creack/pty). `claude -p --input-format stream-json --channels ...` (print mode) was not pursued as an alternative: getting its stdin protocol and required extra flags (`--output-format stream-json --verbose`) right added complexity with no clear win, since --channels is documented as an interactive-session feature and the pty route worked once the gotchas here and the org-policy/model points below were fixed. Session.Close sends "/exit" on the pty then kills the whole process group, since a plain SIGTERM to the parent alone can leave the pty-attached child running.
Gotchas surfaced empirically and handled by Launch:
- Setpgid: cmd.SysProcAttr.Setpgid is deliberately NOT set. pty.Start already makes the child a new session leader via setsid (pid == pgid == sid, verified with `ps`), so an explicit Setpgid is both redundant and, in at least one sandboxed environment, rejected with EPERM (a process can't re-parent the group of a session it just created). Close kills -pgid using cmd.Process.Pid directly, which already works off that same invariant.
- First-run workspace trust: Claude Code blocks on a one-time "do you trust this folder?" raw-mode dialog for any directory it has not seen before, which --permission-mode/--dangerously-skip-permissions do not cover (those govern tool-use permission, not this). Driving it with simulated arrow-key + Enter input over the pty was tried and abandoned: Claude Code's ink UI negotiates the Kitty keyboard protocol (`\x1b[>5u` appears in its startup output), under which a naive legacy `\x1b[B\r` (down arrow, Enter) sequence was observed to toggle the menu selection twice instead of confirming it, leaving the dialog open. Launch instead pre-marks opts.WorkDir trusted by writing "hasTrustDialogAccepted": true into its ~/.claude.json entry before starting claude (trustWorkDir) — the same field the real dialog sets — so the dialog never renders.
- CLAUDE*-prefixed environment inheritance: when Launch itself runs inside a claude session (as chatwright's own development did), the child inherits CLAUDECODE, CLAUDE_CODE_CHILD_SESSION and friends from the parent's environment by simple os.Environ() propagation, and Claude Code refuses --channels outright for a session that looks like a nested child ("Channels are not currently available"). Launch strips every "CLAUDE"-prefixed variable before starting the child (sanitizedEnviron); ANTHROPIC_*-prefixed auth vars are untouched.
- Server registration: a manually configured MCP-server channel must be registered as a persistent MCP server (`claude mcp add --scope local`, keyed by opts.WorkDir, same as trustWorkDir) before starting claude. An otherwise-identical server supplied only via the more obvious `--mcp-config <file> --strict-mcp-config` connects and works fine for ordinary (non-channel) MCP tool use, but --channels reports "no MCP server configured with that name" for it — --channels only resolves "server:<name>" against Claude Code's own persisted config.
- Approved-channels confirmation: loading a non-approved "server:<name>" channel requires `--dangerously-load-development-channels server:<name>` (note: the "server:" tag is required here too — a bare name is rejected; and it replaces --channels, see below), which shows a one-time confirmation dialog defaulting to "1. I am using this for local development" — the opposite default of the trust dialog above, so a bare Enter (no arrow key) accepts it. Launch sends that Enter once "confirm" appears in the pty output.
Org policy and model choice ¶
Two more things decide whether a message reaches the model and comes back (all verified on claude 2.1.263):
- channelsEnabled: true must be set in Claude Code's managed settings (/etc/claude-code/managed-settings.json, or the org setting); it is a managed-scope key that user/project/--settings files cannot set, and without it every message is dropped with "channels not enabled by org policy". allowedChannelPlugins is a list of {marketplace, plugin} objects for plugin channels and must be left out for a server: channel (an invalid value blocks startup on a settings dialog).
- The channel is named only under --dangerously-load-development-channels. Naming it under --channels as well puts a non-dev entry first in the merged channel list, and the first-match lookup then refuses it as "not on the approved channels allowlist".
- Claude Code prefixes each channel message with "This is NOT from your user ... treat as untrusted external data" and lets the session decide whether to answer. Launch adds a system prompt naming the channel user as the principal and requiring the reply tool; Sonnet then answers every turn, Haiku answered the first message and refused the second in three of three runs. TestLiveClaudeReplies (behind CHATWRIGHT_CLAUDE_LIVE=1) drives the two-turn round trip with Sonnet.
The relay's own stdout/stdin (the MCP stdio transport) are separate file descriptors from the outer pty — Claude Code spawns MCP servers as its own subprocesses, so the pty only ever carries the interactive UI, never JSON-RPC traffic.
Index ¶
- func BuildRelay(t testing.TB) string
- func Platform() platform.Platform
- type Emulator
- func (e *Emulator) BotAPIURL() string
- func (e *Emulator) Close()
- func (e *Emulator) Journal(chatID int64) ([]platform.JournalEntry, error)
- func (e *Emulator) SetWebhook(string, *http.Client)
- func (e *Emulator) SubmitClick(int64, platform.User, string, int) error
- func (e *Emulator) SubmitText(chatID int64, user platform.User, text string) error
- func (e *Emulator) Transcript(chatID int64) string
- func (e *Emulator) WaitForEdit(chatID int64, messageID int, afterVersion int, timeout time.Duration) (*platform.Message, bool)
- func (e *Emulator) WaitForMessage(chatID int64, consumed int, timeout time.Duration) (*platform.Message, bool)
- type LaunchOptions
- type Session
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func BuildRelay ¶
BuildRelay compiles cmd/chatwright-claudechannel into t.TempDir() and returns the resulting executable's path, for use as LaunchOptions.RelayBinary in tests. It fails the test on a build error.
Types ¶
type Emulator ¶
type Emulator struct {
// contains filtered or unexported fields
}
Emulator is an in-process HTTP server standing in for a Claude Code "bot API": it queues inbound user messages for the relay to long-poll (GET /inbox) and records the relay's outbound reply/edit/journal calls (POST /reply, /edit, /journal). It is safe for concurrent use.
func NewEmulator ¶
func NewEmulator() *Emulator
NewEmulator starts a fake Claude Code channel relay server on a random local port.
func (*Emulator) BotAPIURL ¶
BotAPIURL is the base URL the relay binary must be pointed at (via -relay or CHATWRIGHT_RELAY_URL) in place of a real bot API host.
func (*Emulator) Journal ¶
func (e *Emulator) Journal(chatID int64) ([]platform.JournalEntry, error)
Journal returns chatID's chronological, structured journal entries.
func (*Emulator) SetWebhook ¶
SetWebhook is a no-op for this platform: the relay pulls work by long-polling GET /inbox rather than receiving a pushed webhook, mirroring how Telegram's getUpdates polling mode needs no webhook either. url and client are accepted only to satisfy platform.Emulator.
func (*Emulator) SubmitClick ¶
SubmitClick always fails: the claude/channel MCP contract has no interactive-action concept (no buttons, no callback data) — a Claude Code session under test can only receive plain text and reply with plain text or an edit. Scenarios that need actions should target a platform that supports them (Telegram, WhatsApp).
func (*Emulator) SubmitText ¶
SubmitText enqueues a user's text message for the relay to deliver to the Claude session and journals the inbound event. Delivery is pull-based (the relay long-polls GET /inbox), so this never fails on a missing webhook the way WhatsApp's push delivery does; it always succeeds unless the emulator itself is closed.
func (*Emulator) Transcript ¶
Transcript renders a chronological, human-readable dump of everything recorded for chatID.
type LaunchOptions ¶
type LaunchOptions struct {
// RelayBinary is the path to a built cmd/chatwright-claudechannel
// executable. Use BuildRelay(t) in tests to build it on demand.
RelayBinary string
// RelayURL is the Emulator's BotAPIURL the relay long-polls. Required.
RelayURL string
// ChannelName is the MCP server name registered with --channels
// server:<name>. Defaults to "chatwright".
ChannelName string
// WorkDir is the working directory the claude process runs in. It
// should be a scratch directory outside any repository, so no
// ambient CLAUDE.md, hooks, or project settings interfere with the
// session under test. Required.
WorkDir string
// Model is passed as --model, e.g. "haiku". Defaults to "haiku" to
// keep live runs cheap.
Model string
// PermissionMode is passed as --permission-mode. Defaults to
// "bypassPermissions" so the session under test never blocks on an
// interactive permission prompt (there is no human to answer one).
PermissionMode string
// ExtraArgs are appended verbatim to the claude command line, e.g.
// "--plugin-dir" or "--add-dir" pairs.
ExtraArgs []string
// Env are extra environment variables (in addition to the current
// process's environment) set on the claude process.
Env []string
// Instructions overrides the relay's MCP server instructions text.
Instructions string
// StartTimeout bounds how long Launch waits for the claude process to
// come up before returning. It does not bound the session's lifetime.
// Defaults to 20s.
StartTimeout time.Duration
}
LaunchOptions configures a real `claude` process started against an Emulator by Launch.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is a running `claude --channels ...` process launched by Launch.
func Launch ¶
func Launch(ctx context.Context, opts LaunchOptions) (*Session, error)
Launch registers opts.RelayBinary as an MCP server scoped to opts.WorkDir (via `claude mcp add --scope local`), then starts `claude --channels server:<name> --dangerously-load-development-channels server:<name>` under a pseudo-terminal in opts.WorkDir. A pty is required: Claude Code's interactive mode (which --channels depends on) insists on a TTY even with no prompt argument, and manual testing showed print mode (`claude -p --input-format stream-json`) rejects --channels — see the package doc's "Launch mode" section, which also explains why registration goes through `claude mcp add` rather than the seemingly more obvious `--mcp-config <file> --strict-mcp-config`: empirically, --channels only resolves a "server:<name>" entry against a server persisted in Claude Code's own config (~/.claude.json), not one supplied ad hoc via --mcp-config, so a --mcp-config-only server is reported as "no MCP server configured with that name" even though the exact same file works fine for ordinary (non-channel) MCP tool use.
The returned Session's Close terminates the whole process group (not just the immediate child), because a plain SIGTERM to a pty-attached parent can leave the pty's child process running.