claudechannel

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildRelay

func BuildRelay(t testing.TB) string

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.

func Platform

func Platform() platform.Platform

Platform returns the Claude Code channel platform for use with cw.OnPlatform. The bot-under-test is a real `claude` process launched via Launch against the returned Emulator's BotAPIURL.

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

func (e *Emulator) BotAPIURL() string

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) Close

func (e *Emulator) Close()

Close shuts down the emulator's HTTP server.

func (*Emulator) Journal

func (e *Emulator) Journal(chatID int64) ([]platform.JournalEntry, error)

Journal returns chatID's chronological, structured journal entries.

func (*Emulator) SetWebhook

func (e *Emulator) SetWebhook(string, *http.Client)

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

func (e *Emulator) SubmitClick(int64, platform.User, string, int) error

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

func (e *Emulator) SubmitText(chatID int64, user platform.User, text string) error

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

func (e *Emulator) Transcript(chatID int64) string

Transcript renders a chronological, human-readable dump of everything recorded for chatID.

func (*Emulator) WaitForEdit

func (e *Emulator) WaitForEdit(chatID int64, messageID int, afterVersion int, timeout time.Duration) (*platform.Message, bool)

WaitForEdit waits for the message identified by (chatID, messageID) to be edited past afterVersion.

func (*Emulator) WaitForMessage

func (e *Emulator) WaitForMessage(chatID int64, consumed int, timeout time.Duration) (*platform.Message, bool)

WaitForMessage waits for the (consumed+1)-th outbound message (original sends only, not edits) to 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.

func (*Session) Close

func (s *Session) Close() error

Close terminates the claude session: it writes "/exit" to the pty to ask for a clean shutdown, then kills the whole process group (claude and any children it spawned, including the relay) and closes the pty. Safe to call more than once.

func (*Session) Output

func (s *Session) Output() string

Output returns everything captured from the session's pty so far (terminal UI included — it is not scoped to any particular turn).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL