Skip to content

Self-management MCP (`paddock_manage`)

Claude can drive Paddock itself — enumerate projects, read another chat’s transcript, spawn and fan out new chats, provision a project, manage a project’s triggers — through an MCP server Paddock injects into Claude’s own turn. The server key is paddock_manage, so the agent sees each tool as:

mcp__paddock_manage__<tool>

There are 14 tools across four capability tiers. Every tier past the first is off by default; a stock instance grants nothing here at all.

Paddock builds an InjectedMcpServerDef per turn and hands it to herdctl as injectedMcpServers, and herdctl auto-allowlists the server’s mcp__<key>__* tools. How the def reaches the agent depends on the runtime: a chat runs on the Claude Agent SDK (the session drive-mode default), where the def becomes an in-process SDK MCP server; the sweeper, triggers, and driveMode: batch chats run as a separate claude -p process that can’t reach an in-process server, so herdctl stands up a localhost HTTP MCP bridge per injected server instead. Either way nothing crosses the network, nothing is authenticated, and no static allowedTools change is needed.

Two consequences worth internalising:

  • The toolset is assembled per turn, not per instance. Which tools exist is decided when the turn is dispatched, from the flags below plus the project the chat lives in. A gate that is off means the tool is absent from tools/list — never present-and-refusing.
  • Arguments are flat scalars. The CLI-runtime MCP transport proved unreliable at carrying array-typed arguments, so list-shaped inputs (prompts, tools) are declared as strings and accept either a newline/comma-separated list or a JSON array. Chats run on the SDK runtime now, but the flat shape is kept — the same tools have to work from a driveMode: batch chat, which does not.

Four independent instance flags, each also settable as a YAML key in paddock.config.yaml (env wins over file).

TierToolsRequires
Readlist_projects, list_chats, read_chatPADDOCK_SELF_MCP / selfMcpEnabled
Writecreate_chat, fork_chat, send_message, archive_chat, unarchive_chat, fork_chat_batchPADDOCK_SELF_MCP_WRITE / selfMcpWriteEnabled and read
Projectcreate_projectPADDOCK_SELF_MCP_PROJECTS / selfMcpProjectsEnabled and write and read
Triggerslist_triggers, set_trigger, remove_trigger, run_triggerPADDOCK_HOOKS_MCP / hooksMcpEnabled (per-project override wins) and write

All four default to false. The nesting is enforced in the config loader itself, not just by convention: selfMcpWriteEnabled resolves to false unless selfMcpEnabled is also on, and selfMcpProjectsEnabled resolves to false unless both of the others are.

Three details that are easy to get wrong:

  • create_project has its own flag on purpose. Every other write tool acts within an existing project. This one mutates instance-level state (a new directory in the projects root, new long-lived agents) and runs git clone on a caller-supplied URL. It is an operator-intent boundary rather than a hard security one — Claude with Bash in a write-enabled project can already clone whatever it likes — but provisioning infrastructure should be opt-in.
  • The trigger gate is the hooks flag, reused. There is no PADDOCK_TRIGGERS_MCP. Epic T collapsed the separate schedule and hook verbs into one trigger family and kept the existing PADDOCK_HOOKS_MCP gate, which a project’s hooksMcpEnabled in project.yaml can override at dispatch.
  • The trigger gate is resolved from the project the chat lives in. The trigger tools all take an optional project argument that can name a different project — the gate that decided whether they exist was resolved against the current one. Enable it per project only if you’re comfortable with that reach; otherwise leave it on the instance default.

scheduleMutationEnabled / PADDOCK_SCHEDULE_MUTATION is not part of this matrix. It only constructs herdctl’s fleet manager with allowScheduleMutation; Paddock arms a schedule trigger by re-registering the project’s agent, and nothing in the self-MCP tool-gating path reads that flag.

maxSpawnDepth — whether a spawned child gets the server at all

Section titled “maxSpawnDepth — whether a spawned child gets the server at all”

The flags above govern a chat a human is driving. A chat that was spawned by another agent (or started by a schedule/event trigger) carries a recorded depth, and that depth decides whether it receives paddock_manage:

A server-initiated turn at depth d gets the self-MCP iff d ≤ maxSpawnDepth.

SettingEnv varDefaultEffect
maxSpawnDepthPADDOCK_MAX_SPAWN_DEPTH1Depth-1 children get the tools (so a child can send_message back to its parent, and can spawn); depth-2 grandchildren do not.
  • 0 — no spawned child gets the server. A manager can fan work out, but the children are terminal: they cannot report back through send_message.
  • n — the tree may grow n spawn-hops deep before the tools stop.
  • Valid values are integers 08; anything else falls back to the default.
  • A per-project maxSpawnDepth in project.yaml (editable from the project’s Settings tab) wins over the instance value at dispatch.

The comparison is because it is evaluated at the child, using the child’s own depth. “A depth-d child may act, d ≤ maxSpawnDepth” is the same bound as “a depth-(d-1) parent may spawn, (d-1) < maxSpawnDepth”.

A human turn is never depth-gated — it is the root of any spawn tree (depth 0), so it is governed by the instance flags alone. The write tier still applies to a spawned child: an operator who left writes off gets read-only children.

Every tool returns a single text block containing JSON — this toolset is read by the agent, so there is no render envelope (unlike send_file).

A failure comes back as an MCP tool result with isError: true and a plain human-readable message, on a successful call — the model needs to read it. So a missing required argument, an unknown model id, an out-of-range value or a store error all arrive as prose, not as a transport-level error.

Two payload caps apply throughout, and both are visible in the output rather than silent:

CapValueWhere
Per-message text2 000 chars, then … [truncated N chars]read_chat messages, and the echoed prompt on write tools
Forks per call20fork_chat_batch

Present whenever PADDOCK_SELF_MCP is on.

Every project argument below is really a workspace key — a path relative to the projects root. The root workspace (the instance’s own top-level directory, “Home” in the sidebar) is a workspace like any other, and its key is the empty string. So project: "" addresses the root, and an absent project is what means “unspecified”.

That distinction is load-bearing, because "" is falsy. Until #560 these tools tested it for truthiness, which made every root chat unreachable — list_chats {"project": ""} silently answered for all projects, and read_chat reported project missing when it had been supplied.

Every project on the instance, across all areas. No arguments.

Returns { count, projects: [{ slug, name, area?, status }], root }. area is omitted when the project has none. Use slug to target the other tools.

root is the root workspace in the same { slug, name, area?, status } shape, with slug: "" — or null when the caller’s scope doesn’t reach it. It is deliberately not a member of projects, and not counted in count: the root is not a project, and enumeration walks the projects root’s children only. It rides alongside instead, exactly as GET /api/projects returns { projects, root }. This is how a caller learns the root exists at all.

ArgumentTypeRequiredNotes
projectstringnoWorkspace key to filter by: a project slug, or "" for the root workspace. Omit to list chats across all workspaces — every project and the root.
include_archivedbooleannoInclude archived chats. Defaults to false, matching the web UI.

Cheap — it does not read transcripts.

Returns { count, omittedArchived, project, chats: [{ project, sessionId, name, updatedAt, running, archived }] }, where project echoes the filter (null when unfiltered — distinct from "", which is the root), updatedAt is the last transcript write and running says whether a turn is in flight. A root chat reports project: ""; pass that value back to read_chat verbatim.

name falls back to an 8-character sessionId prefix when the chat has no stored title. Read that as untitled — it is not a meaningful name, and it is not a usable id, so don’t pass it anywhere a full session_id is wanted.

Archived chats are hidden by default. The web UI files them into a collapsed “Archived” section, and this tool now agrees — on an instance with a few hundred chats the archived ones would otherwise dominate the response. omittedArchived reports how many were withheld, so the filter is never silent.

That matters more than it looks: list_chats is the only way anything discovers a session_id. Hide an archived chat without saying so and it becomes unaddressable — no read_chat, no unarchive_chat, and no clue why. Pass include_archived: true whenever you need one back.

A trimmed tail of a chat’s transcript.

ArgumentTypeRequiredNotes
projectstringyesWorkspace that owns the chat: a project slug, or "" for the root workspace. Use whatever list_chats reported, verbatim. Required means present"" is a valid value, an absent argument is the error.
session_idstringyesFrom list_chats.
limitnumbernoTrailing messages to return. Default 30, max 200; out-of-range values are clamped, not rejected.

Returns { project, sessionId, total, returned, messages: [{ role, text, timestamp }] }. total is the full transcript length and returned the tail size, so the agent can tell it is looking at a window. role is user, assistant or tool; each text is capped at 2 000 characters.

Present when PADDOCK_SELF_MCP_WRITE is on and the read tier is on. These start real turns through the same engine the web UI drives, so a spawned chat appears in the sidebar, streams live, and is re-attachable.

Every write tool takes an optional project slug that defaults to the project the calling chat lives in.

The three spawning tools take an optional model for the spawned chat’s kickoff turn only — it does not change the project default. It is validated against the same allow-list the web model picker uses; an unrecognised id is refused with an actionable error listing the valid ids rather than silently ignored.

ArgumentTypeRequiredNotes
promptstringyesThe full first turn for the new chat.
projectstringnoDefaults to the current project.
namestringnoStrongly recommended — a concise 3–5 word title. Without it the title falls back to a long auto-summary of the first turn.
preload_contextbooleannoSeed the new chat with the project’s OVERVIEW.md + CHANGELOG.md.
modelstringnoModel for this chat only.

Returns { created: true, project, sessionId, name, model, prompt } — the prompt is echoed (truncated) so the tool call renders with the real message.

The new chat appears nested under the chat that called this tool, so a fan-out folds up as one family in the sidebar. Called over the external /mcp instead there is no calling chat to nest under, and the new chat is a root.

Fork an existing chat into a new child that inherits its history, then optionally kick the child off.

ArgumentTypeRequiredNotes
session_idstringnoSource chat. Omit to fork the CURRENT chat (the one the agent is in).
projectstringnoDefaults to the current project.
promptstringnoOptional kickoff turn. A fork with no prompt runs no turn.
namestringnoDisplay name for the fork.
modelstringnoApplies to the kickoff turn only — so a model without a prompt has no effect.

Returns { forked: true, project, sessionId, from, name, model, prompt }.

A fork nests under its source — the chat named by session_id — not under the chat that called the tool. Forking someone else’s chat therefore files the result beside that chat’s own children, not beside yours.

Fails with no chat to fork (current chat id not yet known — pass session_id) if it defaults to the current chat before that chat’s id has resolved.

Send a new turn to a chat that already exists — the way a child reports back to its parent.

ArgumentTypeRequiredNotes
session_idstringyesTarget chat, from list_chats.
promptstringyesThe message to send as a new turn.
projectstringnoDefaults to the current project.

Returns { sent: true, project, sessionId, prompt }.

File a chat into (or out of) the collapsible Archived section. Presentational metadata only — the transcript is untouched and the chat stays openable, resumable and forkable.

ArgumentTypeRequiredNotes
session_idstringnoOmit to archive/unarchive the CURRENT chat — i.e. yourself.
projectstringnoDefaults to the current project.

Returns { archived, project, sessionId }, where archived is true for archive_chat and false for unarchive_chat.

archive_chat powers the self-reporting convention: an agent does its work and then archives itself on success, so an un-archived chat is the signal that something wants a human’s attention. Archiving is also the lifecycle event an onArchive trigger fires on.

The fan-out primitive: fork one source chat into many children at once, one per directive, each kicked off with its own prompt. The classic use is “I found N items, give me one worker per item”.

ArgumentTypeRequiredNotes
promptsstringyesThe fork directives, one per line (1–20 lines). A JSON array of strings is also accepted.
session_idstringnoShared source. Omit to fork the current chat.
projectstringnoDefaults to the current project.
name_prefixstringnoEach fork is named "<name_prefix> <i>", 1-based.
modelstringnoApplies to every fork’s kickoff turn.

Returns { count, source, model, forks: [{ sessionId, prompt }] }. The forks are created concurrently; herdctl enforces the real concurrency cap downstream.

More than 20 directives, or any blank entry, is refused with an explicit error rather than partially executed.

Present only when PADDOCK_SELF_MCP_PROJECTS is on, on top of write and read.

Provision a whole new project — its directory, project.yaml, seeded notes files and, when repo-backed, a cloned nested checkout — and register its agent.

ArgumentTypeRequiredNotes
namestringyesDisplay name.
slugstringnoKebab-case (lowercase a-z, 0-9, single hyphens). Omit to derive it from name.
repostringnoA git URL (https://, git://, ssh://, or git@host:owner/repo). Supplying it makes the project repo-backed: the repo is cloned into a nested checkout that becomes the agent’s working directory. Omit for a notebook project.
summarystringnoOne-line description.
areastringnoThe grouping shown in the sidebar.
statusenumnoOne of idea, active, paused, blocked, done, abandoned. Default active.

Returns { created: true, slug, name, dir, workingDir, repoBacked, repo?, keeperRegistered }. dir is the project’s metadata directory; workingDir is the agent’s cwd (the nested checkout when repo-backed, otherwise dir).

Two things this tool guarantees, and one it doesn’t:

  • It is the same code path as POST /api/projects — the same store create followed by the same agent registration, in the same order, so the REST and MCP paths cannot drift. All validation, the clone, and its rollback live in the store.
  • A bad or unreachable repo URL leaves nothing behind. The whole project directory is rolled back on a clone failure, so it is safe to retry with a corrected URL. Server filesystem paths are stripped from the error before the agent sees it (a git clone failure otherwise surfaces the entire argv).
  • keeperRegistered: false is not a failure. Mirroring the REST route, the project is created even if agent registration fails — but it is reported, because a project with no live agent cannot accept a create_chat yet.

Present when PADDOCK_HOOKS_MCP (or the project’s hooksMcpEnabled override) is on and the write tier is on. These are the unified Epic T verbs — they replaced the separate set_schedule / set_hook families, and manage all trigger types (schedule, event, and the reserved webhook) through one surface.

All four take an optional project slug defaulting to the current project.

ArgumentTypeRequired
projectstringno

Returns { project, count, triggers: [...] }. Each trigger is a flat record regardless of type: name, agentName, type, the when-fields (cron, interval, event, pathnull when not applicable), the run (prompt, promptFile, session, tools, model, permissionMode, maxSpawnDepth, maxTurns) and enabled. For an armed schedule trigger, best-effort live state is merged in: status, lastRunAt, nextRunAt, lastError. Read-only.

Create or update a trigger, keyed by name. There is no separate enable/disable verb — that is this call with enabled flipped. A brand-new trigger defaults to enabled: false.

ArgumentTypeNotes
namestringRequired. The trigger’s stable key.
typeschedule | event | webhookThe when. Omit on an edit to keep the existing one; supplying it re-specifies the trigger.
cronstringSchedule: a 5-field expression, host-local. Exactly one of cron/interval.
intervalstringSchedule: a duration such as 30m or 1h.
eventstringEvent: the lifecycle event (v1: onArchive).
pathstringWebhook: the ingress path. Reserved — nothing fires it yet.
promptstringInline instruction. Provide this or prompt_file.
prompt_filestringA .md file under the project’s .paddock/triggers/, read at fire time.
sessionnew | resumenew (default) = a fresh chat each fire; resume = accrete into the trigger’s one owned session.
toolsstringThe fired agent’s deny-by-default allow-list — one per line or comma-separated. Omitted/empty = a tool-less curator. A JSON array is also accepted.
modelstringModel override for the fired agent.
permission_modedefault | acceptEdits | bypassPermissions | planPermission mode the fired turns run under.
max_spawn_depthnumberRecursion bound for the fired agent’s own spawning (0 = may not spawn).
max_turnsnumberTurn bound on a runaway trigger.
enabledbooleanDefaults false on a new trigger; omitted on an existing one leaves it unchanged.

Edits are a patch: a field you omit is preserved, so an enabled-only call just flips the toggle. Supplying prompt clears an inherited prompt_file and vice versa. A type you supply without its required when-field (a schedule with neither cron nor interval, an event with no event) is refused with a specific message.

Returns { set: true, project, trigger } — the saved trigger in the same flat shape list_triggers returns.

The full project.yaml schema behind these fields lives in the Hooks reference and the Schedules reference.

ArgumentTypeRequired
namestringyes
projectstringno

Deletes it from project.yaml and disarms its agent/schedule. Safe when absent.

Returns { removed, project, name }removed is false when no such trigger existed, which is a success, not an error.

Fire a trigger now, on demand — through the same path a cron or event fire uses, so the resulting chat is a first-class, badged run rather than an ad-hoc chat.

ArgumentTypeRequired
namestringyes
projectstringno

Works for any trigger type and regardless of its enabled flag — a manual run is a deliberate act. This is how an agent tests a trigger it just wrote, or kicks one off out of band.

Returns { ran: true, project, name, sessionId } — the started chat’s id.

Two refusals to expect: an unknown trigger (or one that started no chat) comes back as “no such trigger, or it did not start a chat”, and the post-turn curator trigger is refused explicitly, because it runs automatically after every turn on the sweeper’s agent and has no on-demand path.

paddock_manage is not the only server Paddock injects. A separate one under the server key paddock provides mcp__paddock__send_file, which renders a file inline in the chat. It is injected on every turn — human and spawned — and is not affected by any flag on this page.

It is documented in Sending files & images; nothing about it is restated here.

The in-process surface vs. the external /mcp API

Section titled “The in-process surface vs. the external /mcp API”

Paddock exposes the same underlying operations two ways, and it is worth being precise about how they differ, because the intuition runs backwards.

In-process (paddock_manage)External (/mcp)
Who calls itClaude inside this instanceA caller outside it — a laptop Claude Code session, CI, a peer Paddock
TransportInjected server over a localhost bridgeAuthenticated streamable-HTTP JSON-RPC
AuthenticationNone — it runs full-trust inside the instanceA bearer token per configured client
What bounds itThe instance flags on this page, plus maxSpawnDepthThe credential’s scope (projects / allow / deny)
Default postureEverything offRead-only

Both go through the same operations layer, which is where policy is enforced — so a new transport inherits identical checks and cannot forget them, and a tool added here appears over /mcp for free.