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.
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +9 -0
- package/dist/help-content.js.map +1 -1
- package/dist/parser.d.ts +0 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +0 -4
- package/dist/parser.js.map +1 -1
- package/docs/adopter-playbook.md +100 -45
- package/docs/language-reference.md +341 -23
- package/examples/skillscripts/classify-support-ticket.skill.md +4 -4
- package/examples/skillscripts/feedback-sentiment-scan.skill.md +9 -20
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/examples/skillscripts/morning-brief.skill.md +4 -4
- package/examples/skillscripts/queue-length-monitor.skill.md +2 -2
- package/examples/skillscripts/service-health-watch.skill.md +2 -2
- package/package.json +1 -1
package/docs/adopter-playbook.md
CHANGED
|
@@ -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
|
|
235
|
+
## Shell binary allowlist
|
|
236
236
|
|
|
237
|
-
**
|
|
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`
|
|
246
|
-
| **Syntax scope** | `SKILLSCRIPT_ENABLE_UNSAFE_SHELL`
|
|
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 ...")` (
|
|
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
|
-
###
|
|
259
|
+
### Three call shapes — `command=`, `argv=`, `unsafe=true`
|
|
259
260
|
|
|
260
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
395
|
+
## Trigger model
|
|
386
396
|
|
|
387
|
-
**
|
|
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
|
-
|
|
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": "
|
|
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.
|
|
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
|
-
###
|
|
517
|
+
### Bridging external sources
|
|
510
518
|
|
|
511
|
-
|
|
519
|
+
Anything that isn't time-based is an external adapter that POSTs to `/event`:
|
|
512
520
|
|
|
513
|
-
- **
|
|
514
|
-
- **
|
|
515
|
-
- **
|
|
516
|
-
- **
|
|
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
|
|
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 —
|
|
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
|
|
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 `"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|