Skip to content

Commit 8c5dab7

Browse files
Supervised Claude Code from Slack: propose a graph, show it, run it only if a human approves (#99)
* plan --approve never parked and go --approve was dropped; both gates are real now Two flags that promised a gate and delivered none. GovernedLoop returned PLANNED before the approval block could fire, so `plan --approve` — the form the Slack gate injects — was inert by ordering; and `_cmd_go` accepted --approve/--approval-timeout for the saved-plan path and passed neither on, so `go <dir> --approve` was accepted and silently ignored. The approval block now precedes the plan_only return (an approved plan is still PLANNED, a denial saves nothing for a later go to pick up), execute_plan wires the same file handshake `plan` uses — one shared gate builder, so the two commands cannot drift on how a parked run asks — and a denied or timed-out go leaves plan.json without an executed_run_id. For the external drivers this exists to serve, `plan --json` now carries the admitted shape as data: run_dir, fingerprint, the proposal's nodes, edges and rationale, and a `mutating` verdict computed against the registry module's own MUTATING_KINDS — with a module that declared nothing read as "assume mutating", never as "declared safe" (RegistryBundle.mutating now distinguishes silence from an empty declaration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * grapharc mcp: an agent may request and check, and can never decide The supervision surface external agents plug into, behind the mcp extra that has waited in pyproject since it was declared. Three stdio tools and deliberately not a fourth: plan proposes through the governed loop and returns the admitted shape as data; show_graph reads a run directory back (rendered summaries only, never raw state); execute re-admits and runs the saved plan — parking on the file handshake exactly when the plan's own record says it can change files, so an all-read-only plan runs on the host's prompt while anything mutating waits for a human at a terminal. There is no approve, deny, or decide tool, and a gate test holds the tool list to that sentence: a client that could call approve() would be approving its own proposal, which is not approval. The server drives the CLI as subprocesses — stdout belongs to the protocol, the --json payloads and exit codes are the tested interface, and a parked execute cannot wedge show_graph. Argv is built, never parsed: no tool accepts a registry, policy or model, because the operator's grapharc.toml in the server's root decides those, and a run_dir a client names is confined to that root. plan.json now records the mutating verdict beside the fingerprint, and a record without the field reads as mutating — an old plan is never assumed safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * One command turns Claude Code supervised: the config, the skill, the boundary grapharc init --claude-code writes the two files that adopt the MCP supervision surface: .mcp.json registering `grapharc mcp` as a project server, and .claude/skills/grapharc/SKILL.md — the contract that routes multi-step and state-changing work through plan -> show the user the graph -> execute, quotes the approve command when a mutating plan parks, and states the never-clauses for the hands the server cannot see: never run grapharc approve, never touch the request or decision files, a timeout means ask rather than retry. The templates are string constants in init's convention, asserted byte-for-byte by the gate tests, and neither file is ever overwritten — a refusal names what exists and half-writes nothing. Cookbook 09 walks the adoption; the deep dive and README state the trust boundary out loud rather than implying it: the MCP gate binds the MCP surface, not a co-located agent's own file tools — the boundary is the working directory, as it is for the Slack workspace — the host's permission prompt is UX in front of the gate, not the gate, and a parked execute lives inside one tool call. ROADMAP gains 9.7, distinct from the still-open MCP *client* in 3.5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * An error event carries the node's spend, so cost and the live view must count it Three defects that all followed from the same stale assumption — that a failed node leaves no terminal event worth reading. `observe.cost` skipped every failed execution, so a run stopped for overspending reported a cost report that disagreed with its own audit trail by precisely the spend the stop was about. The kernel stamps `error` terminals with the node's tokens exactly as it stamps `end`, and `metrics.summarize` already counted them; cost now does too, prices them, and keeps `tokens_before_error` for the narrower case it was really about — a trace whose `error` event carries no count of its own, where the AgentNode sub-steps are the only record. The live view double-counted: sub-step tokens accumulated into `live_tokens` were never cleared at a terminal, so a node that ran, finished and ran again showed its closed execution's spend twice. And `go` never resolved `policy` from `grapharc.toml` the way `plan` does, so a saved plan re-admitted through the gate was judged by the built-in default rather than the operator's document. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * The lockfile catches up with the mcp dev dependency, and adoption output is ignored uv.lock records the `mcp` dev dependency added with `grapharc mcp`, so its gate tests run in CI rather than silently skipping. The `.mcp.json` and skill that `grapharc init --claude-code` writes when this repo dogfoods its own supervision join the generated-not-authored list, alongside the runtime residue ignored above. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Supervised Claude Code from Slack: the graph is the answer, and a human presses the button The loop the README promises had no exit. A Slack-launched `plan --approve` parked, a human approved, and the run returned "awaiting `grapharc go`" — into a subcommand the gate does not carry. `--go` is admitted now, and from Slack it *forces* `--approve`: anyone in the workspace can type into that bot, and the difference between proposing a graph and running one on the host always goes through a person. What comes back is the proposal itself, in the message. Not a link — the person deciding is on a phone, and "the graph is over there" asks them to approve something they have not seen. Kinds ride alongside names because only the kind is governed: `fix_it` is a label a planner chose, `apply_change` is the thing the gate rules on, and a node that can change files is marked. A registry that declares no MUTATING_KINDS marks every node and says so. Approve/Deny buttons answer the file handshake by fingerprint, so a button on a message scrolled back to — drawn for a plan the run has since replaced — is refused rather than honoured. Who clicked is posted in the thread and is deliberately *not* claimed by the trace, which has no actor field. Two budgets, because 120 seconds is generous for `metrics` and a SIGKILL through the middle of an approved run: readers keep the short timeout, and anything that executes gets `GRAPHARC_SLACK_WORK_TIMEOUT` (30 min). A parked `--go` gives the human a third of it, capped, and leaves the rest for the work they authorised. Bugs found by building this, each on the path it broke: - the stdlib registry could not delegate to Claude Code *at all*. Its harness used LocalExecutor, `Harness(workspace=...)` was silently discarded whenever an explicit executor was passed, and `_run_delegated` asked only the executor — so every agent phase of the one registry+backend combination whose documented behaviour is delegation failed with "does not expose one". - `mcp.driver.graph_status` read a running trace with the strict reader and raised on the half-written last line — the supervised agent's own status tool, crashing at exactly the moment it is meant to be useful. Every other live-trace reader already used TailRecorder; this was the holdout. - a curtailed phase folded `result.output` into state, which is empty by contract for every stop reason but TARGET_MET, so its work was dropped; and `goal_met` counted notes, so a run whose entire output was `['[error] ']` reported success (#96). - a delegated run's cost was dropped by `attribute()` (orphan cost was read only from `model` events) while `ReplayedRun` counted it — two readers of one trace disagreeing about the bill. - `mutating or stdlib.MUTATING_KINDS` collapsed "declared nothing mutates" with "declared nothing", so a custom registry's real mutating kind went unnamed in a generated — and then cached — policy. - the live view called a multi-phase run done at the first agent phase's stop. - an exhausted deadline spawned Claude Code only to kill it on the first wait. `approve` grows `--show` (print the parked plan, decide nothing) and `--fingerprint` (decide only if this is still the plan you read, exit 2 naming both otherwise), and a parked run now announces the fingerprint, the node list, the deadline and both commands that end the wait (#46, #52). Four recordings under docs/demo/, with what is and is not staged written down next to each: the gate refusing, GraphARC fixing that graph_status bug in a copy of itself under its own policy, one trace answering every question, and the Slack thread end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1d289e3 commit 8c5dab7

58 files changed

Lines changed: 4886 additions & 181 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,9 @@ __marimo__/
232232
# A policy generated because no `--policy`, no `grapharc.toml` and no existing
233233
# one was found. Generated rather than authored: the design is that you *promote*
234234
# it — read it, edit it, move it, pass `--policy` — instead of committing it from
235-
# where a tool dropped it. `grapharc.toml` itself is deliberately NOT ignored;
236-
# that one is yours and belongs in version control.
235+
# where a tool dropped it. In a project of your own, the scaffolded
236+
# `grapharc.toml` is authored config and belongs in version control; in *this*
237+
# repo the root one is dogfooding residue, ignored with the rest of it below.
237238
.grapharc/
238239

239240
# Durable claim stores from `grapharc demo --memory PATH`, and the two sidecars
@@ -247,7 +248,30 @@ __marimo__/
247248
/workspace/
248249
/scratch/
249250

251+
# Dogfooding residue at the repo root: the `grapharc init` scaffold pair, the
252+
# registries that drive the demo films, and the `app/` directory a bench
253+
# shakeout wrote its success condition into. None of it is project source, and
254+
# none of it — the bench residue especially — should ride along on a push.
255+
# Root-anchored, so `bench/fixture/app/` (a committed part of the bench) is
256+
# untouched.
257+
/registry.py
258+
/grapharc.toml
259+
/demo_registry.py
260+
/sample_incident.py
261+
/app/
262+
250263
# Claude Code's per-machine settings. Already covered by many people's global
251264
# ignore; named here so a fresh clone does not depend on that being true.
252265
.claude/settings.local.json
253266
HANDOFF.md
267+
268+
# Generated by `grapharc init --claude-code` when this repo dogfoods its own
269+
# supervision. Generated rather than authored, like the policy above: promote
270+
# a copy into a project you are governing instead of committing the one a
271+
# smoke test dropped here.
272+
/.mcp.json
273+
/.claude/skills/
274+
275+
# The demo recording is regenerated by docs/demo/capture_supervised_slack.py;
276+
# it embeds absolute paths from whichever machine made it.
277+
docs/demo/session.json

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and "used to be true" — the two things a reader most needs kept apart.
77

88
Entries are newest-last within a release, matching the order they were written.
99

10+
## Unreleased
11+
12+
- **the supervised loop had no exit from Slack.** A Slack-launched `plan --approve` parked, showed a human the run, was approved — and then returned `stopped: planned (awaiting `grapharc go`)`, into a subcommand the bot's gate does not carry and cannot be talked into carrying. So the one exchange the bot exists for (ask for work, see the graph, let it run) stopped one step short of running anything, and the approval it collected authorised nothing. `plan --go` is admitted from Slack now, and admitting it is only safe because of the rule that came with it: **from Slack, `--go` forces `--approve`**, on every registry, whether or not the requester typed it. Anyone in a workspace can type into this bot; without that rule one message would take a model's proposal straight to execution on the host with the graph visible only afterwards. It is forced rather than refused so the useful command stays one message — propose it, show me, run it if I say yes.
13+
- **the graph a human was asked to approve was not in the message they were asked to approve it from.** The parked status message said "planned graph is in the live view link" and left the reader to open a URL — on a phone, from a chat client, to see the thing they were being asked about. The proposal is rendered into the message now: the planner's rationale, every node, every edge, and the worst-case token estimate the gate computed. Two things ride with it. First, **kinds, not just names**: `ProposedNode.kind` is what admission governs and the name is a label the planner picked freely, so a node named `fix_it` of kind `apply_change` used to read as harmless — the kind is shown, and a kind the registry declares mutating is marked `✎`. Second, a registry that declares no `MUTATING_KINDS` marks *every* node and says so, matching the fail-closed reading `plan` already takes when it writes `mutating: true` into a plan file it cannot vouch for; the bot resolves the registry from the flag or the working directory's `grapharc.toml` and refuses to import anything outside the shipped set to find out.
14+
- **the app manifest enabled interactivity and nothing used it.** Approving meant typing `/grapharc approve slack-runs/20260808-130940-1b43a32a/trace.jsonl` — a generated path, from a phone. A parked run's message now carries Approve and Deny buttons, and a click writes the same decision file the typed command writes. The click is **bound to the plan's fingerprint**: a parked run rewrites its request every round, so a button on a message scrolled back to names a proposal that is no longer the one waiting, and approving it would be approving a graph nobody read. That is refused at the handler and would be discarded by `file_approval` besides. The button's directory is re-confined inside the working directory exactly as a typed path is — a signed payload says the click is genuine, not that the button was drawn by a version of the bot that meant the same thing by it. The buttons leave on the same edit that reports the run started, because a finished run still showing a live Approve button is a lie a later click would act on. Who clicked is posted into the thread and **not** into the trace, which has no actor field; the bot will not imply an audit trail it does not have.
15+
- **two minutes was the whole budget for work that takes minutes.** One timeout bounded every Slack command, defaulted to 120s so a runaway `metrics` could not hold a worker thread — and a delegated Claude Code phase reads files, runs things and writes a report. That is not a safety limit for such a run, it is a SIGKILL through the middle of one a human just approved. Commands that *execute* (`agent`, `plan --go`) draw on a separate `GRAPHARC_SLACK_WORK_TIMEOUT` (default 1800s); readers keep the short one. The human's share is carved out of whichever applies — a third for a parked `--go`, capped at 15 minutes, so saying yes leaves the run its budget; half for a plan-only park, which has nothing to do afterwards. A requester-supplied `--approval-timeout` larger than the budget is now refused rather than silently outliving the runner: a park that outlives its kill does not report `approval_timeout`, it dies mid-wait.
16+
- **the stdlib registry could not use Claude Code at all** — the one combination its own module docstring describes. Given a backend with no tool-calling wire format, every agent-backed kind delegates its whole loop to Claude Code; delegation needs a directory to run in, and it looked for one on `harness.executor`, where only the sandboxing executors have it. The stdlib harness uses `LocalExecutor`, which has none, so every `investigate`/`verify`/`summarize` phase of every such run failed with *"the delegated executor needs a workspace directory"* before spawning anything. Underneath it was a quieter defect: `Harness(..., executor=…, workspace=…)` **silently discarded `workspace`**, which existed only to construct the default executor — three call sites in this repo pass both and got nothing for it. The harness records its workspace now, whichever executor is in play, delegation asks the harness before the executor, and the stdlib harness names the directory its tools are already confined to.
17+
- a phase the budget curtailed **reported nothing at all**. `AgentResult.output` is empty by contract for every termination reason but `TARGET_MET` — the mid-work text lives in `partial_output` — and the stdlib phase body formatted `output` regardless, so a curtailed phase wrote `[budget_exhausted] ` with nothing after it. The work it did manage was dropped, and a downstream goal check read an empty note as a phase with nothing to report rather than one cut off. It reads `partial_output`, falling back to the reason line.
18+
- a **delegated run was billed at zero**. `grapharc agent --executor claude-cli` drives an `AgentNode` with no enclosing graph, so every event it writes is an orphan, and it reports its spend on the `stop` event; `observe.cost` attributed orphan cost only from `model` events, so the recorded cost came out `$0.00` with `unpriced_tokens` at zero too — nothing said the figure was incomplete. Meanwhile `ReplayedRun.recorded_cost_usd`, which counts orphans by cost rather than by phase, reported the real number: two readers, one trace, two answers. Cost is attributed from any orphan carrying one now; only a `model` event still becomes a row in the model-call breakdown, because only that is a model call.
19+
- the live page **closed the stream while the graph was still running**. "Finished" was any `stop` event in the run — but every `AgentNode` phase writes its own `stop` when its agent loop ends, so a three-phase graph was declared done the moment the first phase finished, and the SSE stream closed on a page with two nodes left to run. Only the *driver's* terminal stop ends a run, and `orphan_sub_events` is how that is read: a phase's stop falls inside its node span and is attributed there, while the driver's is an orphan — and a run with no graph at all (`grapharc agent`) has only orphans, so its stop still ends the stream exactly as before.
20+
- policy generation **substituted another registry's dangerous kinds for a custom registry's silence**. `mutating or stdlib.MUTATING_KINDS` collapsed three states into two: a registry declaring `()` ("none of mine mutate") and a registry declaring nothing at all both became stdlib's `("apply_change",)`. So a registry whose real mutating kind is `deploy` or `publish` had that kind go unnamed while the model was told to guard a name the registry does not contain — and the resulting permissive policy is cached and governs every later run. A declared tuple is now used verbatim, empty included; `None` means nobody said, and every kind in the catalog is named dangerous.
21+
- a delegated run whose deadline had already passed **spawned Claude Code in order to kill it**. `remaining_seconds()` is `max_seconds - elapsed` and goes negative once the budget is spent; `subprocess.run` accepts a negative timeout, starts the child, and kills it on the first wait — so an over-budget run launched the CLI, tore it down mid-startup, and reported `max_seconds (-3.2) reached`, a number no caller set. An exhausted deadline is refused before anything is spawned.
22+
- the supervised agent's own status tool **crashed on the runs it was watching**. `mcp.driver.graph_status` read the trace with `TraceRecorder`, the strict reader, which raises `TraceReadError` on a half-written last line — and a half-written last line is the normal state of a file something is still appending to. An agent polls `show_graph` precisely *while* its run is in progress, so the one moment this had to answer was the one moment it raised, out of the MCP server, as a crash rather than a status. The rest of the codebase already had the answer: `TailRecorder` skips the torn line, which is how the live view and the Slack tailer read a running run. `graph_status` uses it now. Found and fixed on camera — `docs/demo/scenarios/fix_bug.py` is the recording, and the fix was then read and landed here deliberately with `tests/test_torn_trace_read.py`.
23+
- **three CLI recordings, on the same terms as the Slack one.** `docs/demo/capture_cli.py` runs a scenario's commands in a pseudo-terminal — so the CLI takes its tty branch and emits the colour a person actually sees, rather than the byte-stable colourless form a pipe would give — and records each command's bytes, exit code and wall clock. `render_cli.py` draws them, parsing exactly `\x1b[…m` (including the `38;5;N` form the CLI emits under a 256-colour terminal) and dropping every other escape rather than half-interpreting it. The three: the admission gate refusing and then admitting, free and deterministic; GraphARC fixing the `graph_status` bug above in a copy of itself, where the interesting frame is the planner's own rationale under a deny — *"Since apply_change cannot be reached by an edge, this round investigates … for a human to act on"*, `mutating: false` — against the five-node `mutating: true` graph the same goal and model produce once a human amends the rule; and one trace file answering `trace`, `replay` and `diff`, including a run priced and refused during admission so that `diff` reports `path 3 -> 0 nodes`.
24+
- **a demo you can re-make and check.** `docs/demo/capture_supervised_slack.py` drives the real Slack path — the real gate, a real planner, the real file handshake, a real fingerprint-checked click, a real delegated Claude Code phase — against a recording sink instead of a socket, and `render_demo.py` turns the recording into the film in the README. What is real and what is mocked is written down rather than implied, and the closing frame is computed from the trace file rather than from any message: `approval_request → approval_response → start`, with `start` last. The same property is asserted against a real CLI subprocess in `tests/test_slack_supervision.py`, so it does not depend on anyone re-recording a video.
25+
1026
## 0.1.5
1127

1228
- a documentation and demo release; no runtime code changed between `0.1.4` and this wheel. The demo film was re-cut to open on the graph itself — frame one is the nine-node incident graph with its first node already running, then the question that built it, then the finished audited run — and the README now leads with it. The README and website stopped describing the project as early and unstable: the status line states the version and the testing discipline, and *Status and limits* became *Limits*, framed as edges that are documented and tested rather than confessed. A PyPI downloads badge joined the badge row. This release exists mostly so the PyPI project page, which renders the README frozen at publish time, catches up with all of it.

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ grapharc serve --live-root .grapharc/runs # live browser view of every run
4343
grapharc replay <trace> <run-id> # reconstruct a run from its trace
4444
```
4545

46+
![A terminal running grapharc plan: round 1 is rejected with edge_denied and never executes, round 2 replans and runs, then viz draws the graph and metrics prints the per-node bill — all from the one trace file.](docs/media/grapharc-cli-gate.gif)
47+
48+
*Free and deterministic — `--scripted` runs the registry's own stand-in planner, so this reproduces exactly on any checkout. Three more recordings, and what is and is not staged in each, in [docs/demo/](docs/demo/).*
49+
4650
Building a graph directly:
4751

4852
```python
@@ -159,6 +163,31 @@ goal_met
159163

160164
Both blocks are executed by `tests/test_readme.py` against every commit, so this page cannot drift from the code.
161165

166+
### What a policy actually does to an agent
167+
168+
Not "blocks the bad call at the last moment" — it decides what the planner is *able to propose*. The planner is told the policy before it plans, so a denied edge changes the shape of the graph rather than producing a refusal to retry.
169+
170+
![A terminal: a failing test, then a policy denying any edge into apply_change, then a plan under that policy proposing three read-only nodes whose rationale says it cannot reach apply_change, then the amended policy, then the same goal producing a five-node mutating graph, then the execution, the diff, and the test passing.](docs/media/grapharc-cli-fix-bug.gif)
171+
172+
That is GraphARC fixing a bug in GraphARC — a real one, from this project's backlog, in a copy of this repository. Under `deny *->apply_change` the planner proposes three read-only nodes and says so itself:
173+
174+
> Since `apply_change` cannot be reached by an edge, this round investigates the torn trace bug and writes findings to notes **for a human to act on**.
175+
176+
`mutating: false`. A person then amends the rule, and the same goal on the same model returns a five-node graph containing `apply_change`, `mutating: true`. Claude Code executes it and the red test goes green. The recording, and exactly what in it is staged (the pacing) and what is not (everything else), is in [docs/demo/](docs/demo/).
177+
178+
## Supervised Claude Code, from Slack
179+
180+
One message asks for work; the answer is the *graph* it intends to run — nodes, their governed kinds, edges, worst-case cost — and two buttons. Nothing executes until a human presses one.
181+
182+
![A Slack thread: the bot replies with the proposed three-node graph and Approve / Deny buttons, the plan is approved, the nodes then run one by one, and the closing frame reads the trace back — approval_request, approval_response, then the first node's start.](docs/media/grapharc-slack-supervised.gif)
183+
184+
```
185+
/grapharc plan "explain what flaky.py does and why it is not reproducible" --go \
186+
--model claude-cli --registry grapharc.stdlib:build_registry
187+
```
188+
189+
`--go` means plan *and* execute. From Slack the gate appends `--approve` to it unconditionally, so the run parks before its first node — a message from anyone in the workspace can propose work, and only a person can start it. The click is bound to the plan's fingerprint, so a button on a superseded proposal is refused rather than honoured. Setup and the full rule list are in the [Slack cookbook](docs/cookbook/07-slack.md); what is and is not mocked in that recording is in [docs/demo/](docs/demo/).
190+
162191
## Where it sits
163192

164193
| | GraphARC | Claude Code | OpenClaw | raw LangGraph |
@@ -179,5 +208,6 @@ The edges are documented, not denied — the full list with mechanisms is in the
179208
- The HTTP API does not yet use the durable session layer.
180209
- On the Claude CLI backend an agent node is *delegated*, not governed.
181210
- Policy documents govern planning; the tool plane still reads CLI flags.
211+
- The MCP gate binds the MCP surface, not the host: an agent with its own file tools in the run directory could forge the approval decision. The trust boundary is the working directory, as it is for the Slack workspace.
182212

183213
Version `0.1.5` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT

ROADMAP.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,17 @@ Everything here works and nothing calls it.
407407
and the answer was negative, `2` could not run at all.
408408
- [x] **9.6 — Streaming output to clients** via SSE, with a `last-event-id`
409409
cursor so a reconnect skips what it already saw.
410+
- [x] **9.7 — MCP supervision server** (`grapharc mcp`, behind the `mcp`
411+
extra that had waited unimported since it was declared). Three stdio
412+
tools — plan / show_graph / execute — and deliberately no approval
413+
verb: a mutating plan parks on the file handshake for an out-of-band
414+
human, a read-only one runs on the host's own prompt, and the verdict
415+
fails closed (an undeclared registry and an old plan.json both read as
416+
mutating). `grapharc init --claude-code` writes the `.mcp.json` and
417+
the skill that adopt it. Distinct from §3.5, which is the MCP *client*
418+
— GraphARC calling third-party tools — and remains open. The trust
419+
boundary is stated in the deep dive: the gate binds the MCP surface,
420+
not a co-located agent's own hands.
410421
- [ ] **9.3 — Cron schedules** and **9.4 — webhook triggers.**
411422
- [ ] **9.5 — Chat channels** (Slack / Discord).
412423

0 commit comments

Comments
 (0)