skillscript-runtime 0.19.12 → 0.19.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -232,9 +232,9 @@ SKILLSCRIPT_HOME=/path/to/adopter skillfile dashboard --config /path/to/adopter/
232
232
 
233
233
  Each instance reads its own config; ports/paths/db files don't collide.
234
234
 
235
- ## Shell binary allowlist (v0.18.8 — BREAKING)
235
+ ## Shell binary allowlist
236
236
 
237
- **v0.18.8 introduces a default-deny operator allowlist for binaries reachable via `shell(...)` ops.** Per the Scott + Perry decision (memory `7aab6f3f`): skill authors are agents, agents are a weak trust anchor (hallucination, prompt-injection, no human-in-loop at scale), and operator-side scoping converts "a human reviews every skill" from discipline into an enforced constraint at the language level.
237
+ **The runtime enforces a default-deny operator allowlist for binaries reachable via `shell(...)` ops.** Skill authors are agents, agents are a weak trust anchor (hallucination, prompt-injection, no human-in-loop at scale), and operator-side scoping converts "a human reviews every skill" from discipline into an enforced constraint at the language level.
238
238
 
239
239
  ### The behavior
240
240
 
@@ -242,25 +242,41 @@ Two **independent** operator axes — do not conflate:
242
242
 
243
243
  | Axis | Operator switch | Controls |
244
244
  |---|---|---|
245
- | **Binary scope** | `SKILLSCRIPT_SHELL_ALLOWLIST` (v0.18.8 new) | Which binaries `shell(...)` can invoke |
246
- | **Syntax scope** | `SKILLSCRIPT_ENABLE_UNSAFE_SHELL` (existing) | Whether bash interpretation (pipes / `$VAR` / `$(...)`) is permitted |
245
+ | **Binary scope** | `SKILLSCRIPT_SHELL_ALLOWLIST` | Which binaries `shell(...)` can invoke |
246
+ | **Syntax scope** | `SKILLSCRIPT_ENABLE_UNSAFE_SHELL` | Whether bash interpretation (pipes / `$VAR` / `$(...)`) is permitted |
247
247
 
248
248
  Behavior matrix:
249
249
 
250
250
  | Skill op | Binary on allowlist | Binary off allowlist |
251
251
  |---|---|---|
252
- | `shell(command="X ...")` (safe) | runs | refused with `ShellBinaryNotAllowedError` |
252
+ | `shell(command="X ...")` (structural) | runs | refused with `ShellBinaryNotAllowedError` |
253
+ | `shell(argv=["X", ...])` (explicit token list) | runs | refused with `ShellBinaryNotAllowedError` |
253
254
  | `shell(command="X ...", unsafe=true)` with `enableUnsafeShell=true` | runs (if `bash` on allowlist) | refused (off-list `bash` blocks ALL unsafe shell) |
254
255
  | `shell(command="X ...", unsafe=true)` with `enableUnsafeShell=false` | refused with `UnsafeShellDisabledError` (syntax axis fires first) | — |
255
256
 
256
257
  **Off-allowlist is final.** The skill author has no in-skill mechanism to escape it — not the `unsafe` keyword, not `# Autonomous: true`, not `approved="reason"`. Binary scope is an operator boundary the author cannot talk past.
257
258
 
258
- ### Pre-upgrade migrationrun this BEFORE you upgrade
259
+ ### Three call shapes `command=`, `argv=`, `unsafe=true`
259
260
 
260
- The default-deny posture means existing skills using `shell()` will refuse to run on first dispatch after upgrade. **Sequence the migration as a discovery step, not a recovery step:**
261
+ Three ways to invoke a binary. Each picks a different point on the safety/expressiveness curve:
262
+
263
+ | Form | When to reach for it |
264
+ |---|---|
265
+ | `shell(command="curl -s https://example.com/${ID}") -> R` | Simple commands, no spaces or quote-special chars in substituted values. The string is whitespace-tokenized + quote-stripped; structural spawn (no shell). |
266
+ | `shell(argv=["say", "-v", "${VOICE}", "-f", "${PATH}"]) -> R` | **Args with spaces, JSON payloads, dynamic strings.** Each list element is exactly one argv token; substitution per element doesn't re-tokenize. Strictly safer than `unsafe=true` — no shell process, no metacharacter interpretation, injection-surface zero. The right tool when a `${VAR}` may contain whitespace. |
267
+ | `shell(command="echo hi && date", unsafe=true) -> R` | Pipes, redirects, shell built-ins, command-substitution. Requires `enableUnsafeShell: true` at the runtime; lint flags every appearance (tier-2 `unsafe-shell-op`). |
268
+
269
+ `argv=` is mutually exclusive with `command=` and with `unsafe=true` (parse errors on either combination — argv is execv-class, there's no shell to opt into). The same allowlist gate applies to `argv[0]`.
270
+
271
+ ### Quote-trap lint (`shell-quoted-var-in-command`)
272
+
273
+ The pattern `shell(command="echo '${VAR}'")` looks safe — quotes around the substitution — but the structural tokenizer respects quotes during the *original* whitespace split, then strips them. After substitution, if VAR contains quote characters (`Jamie's`) the matching can drift. Tier-2 lint `shell-quoted-var-in-command` fires on this pattern and points at the `argv=` form as the safer answer. Works fine for known-simple values; the lint exists because the failure mode is silent-wrong, not loud-error.
274
+
275
+ ### Discovering your corpus's binaries
276
+
277
+ The default-deny posture means a freshly-installed runtime with no allowlist wired refuses every `shell()` call. Use `skillfile shell-audit` to enumerate the binaries your skill corpus invokes:
261
278
 
262
279
  ```bash
263
- # 1. Run while still on v0.18.7 (or any prior version) — pre-upgrade discovery
264
280
  skillfile shell-audit
265
281
 
266
282
  # Sample output:
@@ -279,15 +295,9 @@ skillfile shell-audit
279
295
  # Note: 'bash' is on the list because at least one skill uses
280
296
  # shell(..., unsafe=true). To permit unsafe shell, ALSO set
281
297
  # SKILLSCRIPT_ENABLE_UNSAFE_SHELL=true.
282
-
283
- # 2. Paste into your $SKILLSCRIPT_HOME/.env, review/narrow as desired
284
-
285
- # 3. NOW upgrade to v0.18.8 — skills find the allowlist already
286
- # populated, no surprise refusals
287
- pnpm install skillscript-runtime@^0.18.8
288
298
  ```
289
299
 
290
- Running the audit *after* the break is fine but adopter-unfriendly operators discover problems through runtime errors instead of explicit decisions. The CLI tool exists precisely to make pre-upgrade the canonical path.
300
+ Paste into your `$SKILLSCRIPT_HOME/.env` (review/narrow as desired). The audit is the canonical path running it lets you make explicit policy decisions instead of discovering refusals through runtime errors.
291
301
 
292
302
  ### Programmatic bootstrap path (`bootstrap()` adopters)
293
303
 
@@ -296,7 +306,7 @@ Running the audit *after* the break is fine but adopter-unfriendly — operators
296
306
  | Surface | CLI path | Programmatic path |
297
307
  |---|---|---|
298
308
  | `.env` (env vars in a dotfile) | auto-loaded from `$SKILLSCRIPT_HOME/.env` | call `process.loadEnvFile()` yourself BEFORE `bootstrap()` |
299
- | `SKILLSCRIPT_*` env vars | read from `process.env` automatically | read automatically once env is loaded (v0.18.9+ env fallback) |
309
+ | `SKILLSCRIPT_*` env vars | read from `process.env` automatically | read automatically once env is loaded (`bootstrap()` env-fallback) |
300
310
  | `connectors.json` (MCP wiring) | auto-discovered at `$SKILLSCRIPT_HOME/connectors.json` | pass `connectorsConfigPath: "/path/to/connectors.json"` to `bootstrap()` |
301
311
 
302
312
  Skip any of these and you get silent-empty surprises — a missing env var, an undeclared connector, etc. — with no hint that the file wasn't loaded. This is intentional: `bootstrap()` stays decoupled from the dotenv + `SKILLSCRIPT_HOME` convention so embedders can drive every input explicitly. The cost is doc-prominence — three surfaces, all silent-empty on omission, all noted here.
@@ -326,7 +336,7 @@ const { mcpServer, scheduler } = bootstrap({
326
336
  });
327
337
  ```
328
338
 
329
- **`bootstrap()` env fallback semantics (v0.18.9+)**: when `opts.shellAllowlist === undefined`, the runtime reads `SKILLSCRIPT_SHELL_ALLOWLIST` from `process.env` (comma-separated, trimmed). When `opts.shellAllowlist` is supplied — including the explicit `[]` deny-all — the option is authoritative and **env does NOT widen it**. This is security-load-bearing: an adopter passing `shellAllowlist: []` to assert lockdown gets lockdown regardless of ambient env.
339
+ **`bootstrap()` env fallback semantics**: when `opts.shellAllowlist === undefined`, the runtime reads `SKILLSCRIPT_SHELL_ALLOWLIST` from `process.env` (comma-separated, trimmed). When `opts.shellAllowlist` is supplied — including the explicit `[]` deny-all — the option is authoritative and **env does NOT widen it**. This is security-load-bearing: an adopter passing `shellAllowlist: []` to assert lockdown gets lockdown regardless of ambient env.
330
340
 
331
341
  | `opts.shellAllowlist` | `SKILLSCRIPT_SHELL_ALLOWLIST` env | Effective allowlist |
332
342
  |---|---|---|
@@ -356,13 +366,13 @@ Argument-level policy (host allowlists, URL pattern matching) and OS-level egres
356
366
  - `execve`/`seccomp` filters (kernel-level allowed-binary set)
357
367
  - Container with curated `/usr/bin` (immutable distroless image)
358
368
 
359
- Per Perry's reframe (memory `7aab6f3f`): parse-based binary enumeration of `bash -c <body>` would be unsound — `e=curl; $e ...`, `$(printf cur)l ...`, `eval`, `xargs`, var-built command names all defeat it. Against agent-author threat models, false confidence is worse than no enforcement.
369
+ Parse-based binary enumeration of `bash -c <body>` would be unsound — `e=curl; $e ...`, `$(printf cur)l ...`, `eval`, `xargs`, var-built command names all defeat it. Against agent-author threat models, false confidence is worse than no enforcement.
360
370
 
361
- ### Dashboard observability (v0.18.9)
371
+ ### Dashboard observability
362
372
 
363
373
  The dashboard SPA at `http://<host>:<port>` exposes two security-focused surfaces for the observe→promote loop:
364
374
 
365
- **Security view (`#security` route).** Cross-skill list of blocked shell attempts — `{skill, target, binary, body, timestamp}` per refused call. Aggregated by binary so you can see at a glance "what did skills try to invoke that I haven't allowlisted." Backed by the new `blocked_shell_attempts` MCP tool, which filters trace records by `blocked_reason: "binary-not-allowed"`. Pre-v0.18.9 runtimes don't expose this tool; the view degrades cleanly to an "upgrade to v0.18.9+" note.
375
+ **Security view (`#security` route).** Cross-skill list of blocked shell attempts — `{skill, target, binary, body, timestamp}` per refused call. Aggregated by binary so you can see at a glance "what did skills try to invoke that I haven't allowlisted." Backed by the `blocked_shell_attempts` MCP tool, which filters trace records by `blocked_reason: "binary-not-allowed"`. Runtimes without the tool exposed degrade cleanly to an "unavailable" note.
366
376
 
367
377
  **Skill detail view — security signals + source highlighting.** Each skill's detail page (clicking a skill name from `#skills`) shows:
368
378
  - A **"Security signals"** panel at the top: aggregated counts of shell ops + binaries used, unsafe-shell count, `# Autonomous: true`, per-op `approved="..."` authorizations, mutation ops (`$ skill_write` / `$ data_write` / `file_write`), wake-class `@session` deliveries, cron triggers.
@@ -370,7 +380,7 @@ The dashboard SPA at `http://<host>:<port>` exposes two security-focused surface
370
380
 
371
381
  The two surfaces compose: summary panel tells you WHAT to look for; highlights tell you WHERE to look.
372
382
 
373
- ### Future direction (NOT shipped in v0.18.8)
383
+ ### Future direction
374
384
 
375
385
  Per-skill capability declaration: skills declare what shell binaries they need in their frontmatter:
376
386
 
@@ -382,16 +392,16 @@ Per-skill capability declaration: skills declare what shell binaries they need i
382
392
 
383
393
  The operator policy validates `declared ∩ allowlist` — each skill's shell footprint becomes self-documenting and auditable. Slated for a future ring once the chokepoint + observability surfaces ship.
384
394
 
385
- ## Trigger model (v0.19.0 — BREAKING)
395
+ ## Trigger model
386
396
 
387
- **v0.19.0 collapses the trigger surface to two primitives.** Per the Scott + Perry decision (memory `ceaf4579`):
397
+ **The trigger surface is two primitives.**
388
398
 
389
399
  | Primitive | Purpose |
390
400
  |---|---|
391
401
  | `cron` | Time-based fires (unchanged) |
392
402
  | `event` | Generic external-signal fires via HTTP POST to `/event` |
393
403
 
394
- The previous sources (`session`, `agent-event`, `file-watch`, `sensor`) are **removed**. They were either parse-only stubs that never fired or substrate-coupled concepts that belong outside the runtime. Anything external becomes an adapter that POSTs to `/event` including what would have been a session/agent-event/file-watch/sensor trigger. The `DeliveryMeta.origin.trigger_kind` receiver enum also drops `"session"` in lockstep (pre-v1.0 cleanup; the value was never functionally emitted).
404
+ Concepts that look like triggers but aren't session lifecycle, agent events, file-watch, sensors are **adapter responsibilities**: external code POSTs `/event` when relevant. Keeps the runtime substrate-neutral; the trigger surface stays tight. The `DeliveryMeta.origin.trigger_kind` receiver enum exposes only `"cron" | "event" | "webhook" | "agent" | "cli" | "dashboard" | "inline"`.
395
405
 
396
406
  ### The `event` primitive
397
407
 
@@ -418,7 +428,7 @@ Authorization: Bearer <token> # if SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN set
418
428
 
419
429
  {
420
430
  "event_name": "prox",
421
- "params": { "ROOM": "kitchen", "USER": "scott" }
431
+ "params": { "ROOM": "kitchen", "USER": "alice" }
422
432
  }
423
433
  ```
424
434
 
@@ -444,8 +454,6 @@ The `run_id` is the runtime's `trace_id` — adopters paste it into the dashboar
444
454
 
445
455
  ### `# Autonomous: true` for event/cron skills doing mutations
446
456
 
447
- This is **not** a v0.19.0 regression — it's the pre-existing v0.14.x mutation gate. But it surfaces more visibly with event-fired skills, so worth calling out explicitly in this section.
448
-
449
457
  Skills fired by cron OR event have **no interactive author** to confirm mutation ops. The runtime's mutation gate (`$ data_write` / `$ skill_write` / `file_write` / mutating MCP tools) requires explicit authorization in non-interactive contexts. Three authorization paths exist:
450
458
 
451
459
  ```
@@ -498,7 +506,7 @@ SKILLSCRIPT_EVENT_INGRESS_ENABLED=true
498
506
  SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN=<token>
499
507
  ```
500
508
 
501
- When `eventIngressEnabled=true`, the route mounts on the same HTTP server as the dashboard/RPC (no second port). Default bind is `127.0.0.1` (localhost-only) — adopters wanting `0.0.0.0` external reach pass `--host 0.0.0.0` explicitly. Per Perry's framing: "enforce the DMZ assumption by the bind, not by hope."
509
+ When `eventIngressEnabled=true`, the route mounts on the same HTTP server as the dashboard/RPC (no second port). Default bind is `127.0.0.1` (localhost-only) — adopters wanting `0.0.0.0` external reach pass `--host 0.0.0.0` explicitly. The DMZ assumption is enforced by the bind, not by hope.
502
510
 
503
511
  ### Durability honesty
504
512
 
@@ -506,20 +514,20 @@ When `eventIngressEnabled=true`, the route mounts on the same HTTP server as the
506
514
 
507
515
  Durable / at-least-once delivery is a v2 if real adopter need surfaces. Don't oversell the 200 contract; build durable queueing on top yourself if you need it now.
508
516
 
509
- ### Migration from removed sources
517
+ ### Bridging external sources
510
518
 
511
- If you have pre-v0.19.0 skills with `# Triggers: session: start` / `agent-event: X` / `file-watch: /path` / `sensor: X` declarations:
519
+ Anything that isn't time-based is an external adapter that POSTs to `/event`:
512
520
 
513
- - **session start/end** boot/shutdown lifecycle moved to the substrate (NanoClaw / your harness). If you need a runtime skill to fire at session-start, your harness POSTs to `/event` after boot.
514
- - **agent-event** external bridge (your agent fires `POST /event` when relevant).
515
- - **file-watch** external watcher script (inotify / chokidar / fswatch) POSTs to `/event`. The "how to call /event" pattern lives in the language reference call-example; the file-watching itself is standard OS plumbing — you own that script.
516
- - **sensor** same pattern as file-watch; sensor adapter POSTs to `/event`.
521
+ - **Session start/end** your harness POSTs `/event` after boot or before shutdown if you want a skill to fire at session boundaries.
522
+ - **Agent events** your agent emits `POST /event` when the event of interest occurs.
523
+ - **File-watch** an external watcher script (`inotify` / `chokidar` / `fswatch`) POSTs to `/event` on changes. The file-watching itself is standard OS plumbing — you own that script.
524
+ - **Sensors** same pattern as file-watch; the sensor adapter POSTs to `/event`.
517
525
 
518
- Skills declaring removed sources fail to parse on v0.19.0 (tier-1 parse error). Run `skillfile lint` against your corpus pre-upgrade to find them.
526
+ Skills declaring unsupported `# Triggers:` sources fail to parse with a tier-1 parse error. `skillfile lint` against your corpus surfaces them.
519
527
 
520
528
  ## Output template — body text IS the output
521
529
 
522
- A skill's body text — the prose between the frontmatter and the first target — is its declarative output template. The runtime interpolates `${VAR}` references against final vars and publishes the rendered string as the skill's canonical output (`outputs.text` or the agent/template/file payload, depending on `# Output:` kind). No `emit()` ceremony required for the common case.
530
+ A skill's body text — prose lines that don't sit inside a target — is its declarative output template. The runtime interpolates `${VAR}` references against final vars and publishes the rendered string as the skill's canonical output (`outputs.text` or the agent/template/file payload, depending on `# Output:` kind). No `emit()` ceremony required for the common case. The template may appear **anywhere a target doesn't** — at the top, at the bottom, or split between targets — so authors can put output where their language instincts suggest (most write the output line *after* the compute that fills it in).
523
531
 
524
532
  ### The shape
525
533
 
@@ -550,7 +558,7 @@ Two independent output channels:
550
558
 
551
559
  When a skill defines both a template AND `emit(text=...)` calls, the template owns canonical output; `emit()` populates transcript only. This is intentional — emit is the debugging / reasoning channel. The `emit-with-template` advisory lint surfaces this to confirm intent.
552
560
 
553
- If you want emit to keep being your canonical output (the pre-v0.19.4 shape), don't write a body template. Emit-only skills work exactly as before.
561
+ If you want emit to be your canonical output, don't write a body template. Emit-only skills route emissions to the canonical output channel.
554
562
 
555
563
  ### Output kinds
556
564
 
@@ -585,13 +593,60 @@ report: needs: fetch_issues
585
593
 
586
594
  The legacy `report: fetch_issues` shape (deps after colon, no `needs:` keyword) is still parsed as a target when an indented op-block follows on the next line, but `needs:` is the recommended form for new skills.
587
595
 
596
+ ### One template region per skill
597
+
598
+ A skill may carry template prose in one region. Splitting it into two — some prose above the targets AND some below — raises a tier-1 parse error: "skill has body template content in two places (before targets AND after targets). Pick one location." Picking is loud over silent-concat; the author chooses where the template lives, the runtime doesn't guess.
599
+
588
600
  ### Lints to know
589
601
 
590
602
  - **`unset-template-var`** (tier-1) — every `${VAR}` in the template must resolve to a `# Vars:` / `# Requires:` input, an ambient ref (`NOW`, `USER`, ...), or a `$set` / `->` binding somewhere in the skill body. Tier-1 because an unbound ref renders empty silently.
591
603
  - **`template-looks-like-target`** (tier-2) — bare `<word>:` alone in the template region, no following op-block. Ambiguous shape — author may have meant a target.
604
+ - **`connector-as-tool`** (tier-1) — `$ <connector> <tool>` space-separated catches the muscle-memory foot-gun (CLI-style `git status`). Bare-form dispatch treats the first token as the tool name, sending `name: "<connector>"` to the MCP server, which replies with a misdirecting "Tool '<connector>' not found." The two correct shapes are `$ <connector>.<tool> args` (dotted) or `$ <tool> args` (bare-tool; runtime resolves).
605
+ - **`remote-result-needs-parse`** (tier-3) — `${R|length}` on an `R` bound by `$` dispatch. Per-MCP-server result shapes vary: if the server returns prose-wrapped or non-JSON text, the value binds as a STRING and `|length` returns the string's char-count instead of element-count. Add `$ json_parse ${R} -> P` after the dispatch and use `${P|length}`. Suppressed when the skill already does `$ json_parse ${R}` somewhere — your defensive parse is taken as intent.
592
606
  - **`body-template-detected`** (tier-3) — non-blank, non-`#` lines in the body region, no `${...}` interpolations, no text-consuming `# Output:` declaration. Suggests "I wrote prose; it became template by accident." Prefix with `#` to mark as comments, or add an interpolation / `# Output:` to confirm intent.
593
607
  - **`emit-with-template`** (tier-3) — skill has both a template AND `emit(text=...)` calls. Confirms the channel-shift is intentional.
594
608
 
609
+ ## Fallback semantics — `(fallback: ...)` op-trailer and `|fallback:` filter
610
+
611
+ Two surfaces, **one emptiness predicate**. Both fire when the upstream value is any of:
612
+
613
+ - empty string after `trim()`
614
+ - empty array (`[]`)
615
+ - `null` / `undefined`
616
+
617
+ ### Op-trailer — `(fallback: "value")`
618
+
619
+ Trails the `-> R` binding on `$` dispatch ops, `shell(...)`, `file_read(...)`. Binds the fallback value to `R` when the op throws OR produces an empty result.
620
+
621
+ ```
622
+ $ ticketing.search query="open" -> ISSUES (fallback: [])
623
+
624
+ shell(argv=["gh", "pr", "list", "--repo", "owner/repo"]) -> PRS (fallback: "No current PRs.")
625
+
626
+ file_read(path="/var/reports/today.md") -> REPORT (fallback: "no report")
627
+ ```
628
+
629
+ When the fallback fires, the runtime pushes a record onto `result.fallbacks[]` so callers can distinguish "real value" from "fallback substituted." The original error message rides on the fallback record's `reason` field; the op completes cleanly.
630
+
631
+ ### Filter — `${VAR|fallback:"value"}`
632
+
633
+ Substitution-time fallback inside a template or kwarg. Same emptiness predicate; binds to the filter argument when the upstream value is empty.
634
+
635
+ ```
636
+ Open PRs: ${PRS|fallback:"none today"}.
637
+
638
+ $ llm prompt="Triage this: ${INPUT|fallback:'(no input — skip)'}" -> JUDGMENT
639
+ ```
640
+
641
+ The filter is positional in chains: `${RAW|trim|fallback:"-"}` applies trim first, then fallback if the trim left an empty string.
642
+
643
+ ### When to use which
644
+
645
+ - **Op-trailer** for *binding-time* protection — the variable lands populated regardless of the op's outcome. Downstream consumers (other ops, template renders) don't need to know whether the value is real or fallback.
646
+ - **Filter** for *substitution-time* defaulting — the variable may legitimately be empty/unset, you just want a placeholder at the render site.
647
+
648
+ Most "no current results" patterns want the op-trailer (the variable becomes the canonical record of what happened). The filter is for "if the optional input wasn't supplied, show a placeholder" cases.
649
+
595
650
  ## Wiring the AgentConnector
596
651
 
597
652
  `AgentConnector` is the substrate-neutral delivery surface for `# Output: agent: X` / `# Output: template: X` lifecycle hooks and `notify()` / `exchange()` ops. The runtime calls into the contract; your impl decides where the payload lands (webhook, tmux session, file drop, IPC pipe, Slack thread, your own agent harness, etc.).
@@ -660,9 +715,9 @@ The canonical bundled example is `examples/connectors/HttpWebhookAgentConnector/
660
715
 
661
716
  Three patterns to copy when forking it for your substrate:
662
717
 
663
- **Pattern 1 — `agent@session` opaque composite.** Every messaging substrate needs either bare-identity OR specific-live-session addressing. The contract keeps `agent_id` opaque; sessions ride as `"perry@kitchen-terminal"` or via `WakeOpts.session_id`. Substrates that care decompose; substrates that don't ignore.
718
+ **Pattern 1 — `agent@session` opaque composite.** Every messaging substrate needs either bare-identity OR specific-live-session addressing. The contract keeps `agent_id` opaque; sessions ride as `"<agent>@<session>"` (e.g. `"alice@laptop-tab-3"`) or via `WakeOpts.session_id`. Substrates that care decompose; substrates that don't ignore.
664
719
 
665
- As of v0.18.5, the runtime address-routes skill-author surfaces (`notify()` + `# Output: agent:` / `template:`) on `@session` presence: bare → your `deliver()`, composite → your `wake()`. You receive whichever method the runtime decided; your job is to honor what arrives. For `wake()`, expect the FULL composite (`"perry@kitchen-terminal"`) — decompose to route to the right session:
720
+ The runtime address-routes skill-author surfaces (`notify()` + `# Output: agent:` / `template:`) on `@session` presence: bare → your `deliver()`, composite → your `wake()`. You receive whichever method the runtime decided; your job is to honor what arrives. For `wake()`, expect the FULL composite (`"<agent>@<session>"`) — decompose to route to the right session:
666
721
 
667
722
  ```typescript
668
723
  async wake(agent_id: string, opts?: WakeOpts): Promise<WakeReceipt> {
@@ -693,9 +748,9 @@ async wake(agent_id: string, _opts?: WakeOpts): Promise<WakeReceipt> {
693
748
 
694
749
  Callers reading `WakeReceipt.woken` distinguish "the substrate woke them" from "the substrate stored the payload for later" without needing per-substrate knowledge.
695
750
 
696
- **Pattern 3 — session echo on receipts.** When your substrate routes to a specific session, echo it back on `DeliveryReceipt.session_id` / `WakeReceipt.session_id`. Dashboards rendering "delivered to perry@kitchen-terminal" rather than just "delivered to perry" depend on this.
751
+ **Pattern 3 — session echo on receipts.** When your substrate routes to a specific session, echo it back on `DeliveryReceipt.session_id` / `WakeReceipt.session_id`. Dashboards rendering "delivered to alice@laptop-tab-3" rather than just "delivered to alice" depend on this.
697
752
 
698
- **Pattern 4 — read `meta.origin.caller_agent_id` to attribute, not for scope.** The `DeliveryMeta` envelope your `deliver()` receives carries `origin.caller_agent_id` = the *authenticated caller* who fired the dispatch (not the skill's owner — those are separate semantics; see [Connector Contract Reference](connector-contract-reference.md) §field semantics). Use it for *attribution* — rendering "from cc" on the receiving end, audit logs, accountability — not for authorization scoping. Outbound substrate scoping should derive from the *skill owner* (which the runtime applies at the connector layer via `ctx.agentId`, not via the envelope). If `caller_agent_id` is undefined on a delivery you receive, it means the chain originated from a non-human trigger (cron / event / scheduler) — your substrate should attribute it as "system-fired" or similar, not assume an identity.
753
+ **Pattern 4 — read `meta.origin.caller_agent_id` to attribute, not for scope.** The `DeliveryMeta` envelope your `deliver()` receives carries `origin.caller_agent_id` = the *authenticated caller* who fired the dispatch (not the skill's owner — those are separate semantics; see [Connector Contract Reference](connector-contract-reference.md) §field semantics). Use it for *attribution* — rendering "from <caller>" on the receiving end, audit logs, accountability — not for authorization scoping. Outbound substrate scoping should derive from the *skill owner* (which the runtime applies at the connector layer via `ctx.agentId`, not via the envelope). If `caller_agent_id` is undefined on a delivery you receive, it means the chain originated from a non-human trigger (cron / event / scheduler) — your substrate should attribute it as "system-fired" or similar, not assume an identity.
699
754
 
700
755
  **Pattern 5 — surface non-fatal notes via `DeliveryReceipt.warnings`.** When your substrate needs to signal something non-fatal about a delivery — "stripped @session because verb is deliver", "rate-limit hint", "fan-out: delivered to 3 sessions" — return them as `warnings: string[]` on the receipt instead of writing to stderr. The runtime echoes warnings onto `AgentDeliveryReceiptRecord.receipt.warnings`, where the dashboard can render them and observability tools can scrape them. Stderr noise gets lost; receipt warnings are structured + caller-visible.
701
756
 
@@ -722,7 +777,7 @@ How `author` is captured depends on how the skill gets written:
722
777
 
723
778
  Adopters whose `SkillStore` is backed by an addressable substrate (e.g., a memory store) can author skills by writing the substrate record directly — without going through the MCP `skill_write` handler. This captures `SkillMeta.author` from the substrate's own writer-identity (whatever the direct-write API authenticates as).
724
779
 
725
- **Gotcha:** direct-write must declare `# Status: Draft`, not `# Status: Approved`. The runtime's hash-token tamper gate (v0.9.0) rejects skills with `# Status: Approved` that lack a `vN:<token>` stamp; the stamp is computed by the runtime's `update_status` flow, not by the substrate. To publish:
780
+ **Gotcha:** direct-write must declare `# Status: Draft`, not `# Status: Approved`. The runtime's hash-token tamper gate rejects skills with `# Status: Approved` that lack a `vN:<token>` stamp; the stamp is computed by the runtime's `update_status` flow, not by the substrate. To publish:
726
781
 
727
782
  1. Write the skill with `# Status: Draft` via your substrate's direct-write API.
728
783
  2. Call `skill_status({name, new_state: "Approved"})` via MCP (or the dashboard's Approve button). This stamps the token and preserves the captured author.
@@ -731,9 +786,9 @@ Write-Approved-without-stamp will fail at execute time with `ApprovalRejectedErr
731
786
 
732
787
  ## Identity propagation — for multi-agent hosts
733
788
 
734
- **Skip this section** if your runtime serves one agent (CLI tools, single-user dashboards, hobby deployments). The existing v0.16.8 default — `SkillMeta.author` captured from the SkillStore's writer identity — already attributes authorship correctly when there's only one writer.
789
+ **Skip this section** if your runtime serves one agent (CLI tools, single-user dashboards, hobby deployments). The default — `SkillMeta.author` captured from the SkillStore's writer identity — already attributes authorship correctly when there's only one writer.
735
790
 
736
- This section is for adopters whose runtime is fronted by an MCP host that bridges multiple authenticated agents into one transport (e.g., a NanoClaw-style multi-agent gateway, or a multi-tenant SaaS where agents share a runtime pool).
791
+ This section is for adopters whose runtime is fronted by an MCP host that bridges multiple authenticated agents into one transport (e.g., a multi-agent gateway, or a multi-tenant SaaS where agents share a runtime pool).
737
792
 
738
793
  ### The gap MCP doesn't close on its own
739
794
 
@@ -753,7 +808,7 @@ When you configure `dashboard.mcpCallerIdentityHeader`, the runtime reads that h
753
808
  }
754
809
  ```
755
810
 
756
- Multi-agent host (NanoClaw, custom MCP gateway, etc.) is responsible for setting the header on every outbound request:
811
+ Multi-agent host (custom MCP gateway, etc.) is responsible for setting the header on every outbound request:
757
812
 
758
813
  ```http
759
814
  POST /rpc HTTP/1.1