skillscript-runtime 0.19.11 → 0.19.13
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 +11 -0
- package/dist/help-content.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +22 -9
- package/dist/mcp-server.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/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +89 -31
- package/dist/runtime.js.map +1 -1
- package/docs/adopter-playbook.md +100 -45
- package/docs/language-reference.md +56 -22
- 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
|
|
@@ -379,7 +379,7 @@ Closed list of language-intrinsic ops the runtime knows directly. Each is a func
|
|
|
379
379
|
| `notify` | `notify(agent="...", message="...", [event_type=...], [correlation_id=...]) -> ACK` | optional | Mid-skill agent alert; synchronous send via configured AgentConnector. |
|
|
380
380
|
| `inline` | `inline(skill="<data-skill-name>")` | none | Compile-time inline of an Approved `# Type: data` skill. Resolves at compile, records `content_hash` in provenance. |
|
|
381
381
|
| `execute_skill` | `execute_skill(skill_name="...", inputs={...}) -> R` | optional | Composition primitive. Runtime-resolved. See Composition section. |
|
|
382
|
-
| `shell` | `shell(command="...") -> R` / `shell(command="...", unsafe=true) -> R` | optional |
|
|
382
|
+
| `shell` | `shell(command="...") -> R` / `shell(argv=[...]) -> R` / `shell(command="...", unsafe=true) -> R` | optional | Structural spawn (default), explicit-argv spawn (`argv=[...]`, no tokenizer), or full-shell exec (`unsafe=true`, gated by `runtime.enable_unsafe_shell`). Binary gated by the operator allowlist (see below). stdout binds. |
|
|
383
383
|
| `file_read` | `file_read(path="...") -> R` | required | Read a file at `path`; binds string contents. |
|
|
384
384
|
| `file_write` | `file_write(path="...", content="...")` | none | Write `content` to `path`. `mkdir -p` semantics for parent directories. Mutation-classified. |
|
|
385
385
|
|
|
@@ -431,18 +431,25 @@ Synchronous alert to a named agent via wired AgentConnector(s). **Contrast with
|
|
|
431
431
|
|
|
432
432
|
Returns ACK `{agent, dispatched: [{connector, ok, error?}]}` — fire-and-forget callers ignore the binding; check-delivery callers inspect ACK.
|
|
433
433
|
|
|
434
|
-
### `shell` —
|
|
434
|
+
### `shell` — structural, explicit-argv, or unsafe exec
|
|
435
435
|
|
|
436
|
-
|
|
436
|
+
Three forms.
|
|
437
|
+
|
|
438
|
+
**1. `shell(command="...")` — structural spawn (default).** The command string is whitespace-tokenized and quote-stripped, then one binary is spawned with the resulting tokens. No shell, no metacharacters, no pipes/redirects. The tokenizer is **quote-aware**: quotes are respected during the whitespace split, so a literal `'hello world'` stays one token (the surrounding quotes are then stripped). stdout binds; non-zero exit → op-error routed through `else:` / `# OnError:`.
|
|
437
439
|
|
|
438
440
|
```
|
|
439
441
|
shell(command="curl -s 'wttr.in/${LOCATION|url}?format=j1'") -> RAW
|
|
440
442
|
shell(command="git status") -> STATUS
|
|
441
443
|
```
|
|
442
444
|
|
|
443
|
-
|
|
445
|
+
**2. `shell(argv=["bin","arg1","${VAR}",...])` — explicit-argv spawn.** Each list element is exactly one argv token; `${VAR}` substitutes per element and the result is **not re-split**, so an arg containing whitespace, quote characters, JSON, or any dynamic content stays one intact arg. No tokenizer, no quote-matching, no shell — strictly safer than `unsafe=true` (injection-surface zero). This is the right form whenever an arg may contain dynamic or whitespace-bearing content. **Mutex:** `argv=` does not compose with `command=` or `unsafe=true` — it's an execv-class spawn, there is no shell to opt into.
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
shell(argv=["say","-v","${VOICE}","-f","${PATH}"]) -> OUT
|
|
449
|
+
shell(argv=["jq","-c","${FILTER}","/tmp/data.json"]) -> RESULT
|
|
450
|
+
```
|
|
444
451
|
|
|
445
|
-
**
|
|
452
|
+
**3. `shell(command="...", unsafe=true)` — full bash.** Required for pipes, redirects, shell built-ins.
|
|
446
453
|
|
|
447
454
|
```
|
|
448
455
|
shell(command="for i in $(seq 1 10); do echo $i; done", unsafe=true) -> R
|
|
@@ -452,11 +459,26 @@ shell(command="curl -s example.com | jq '.field' > /tmp/out", unsafe=true)
|
|
|
452
459
|
- Lint flags every `unsafe=true` call as tier-2.
|
|
453
460
|
- Runtime refuses with `UnsafeShellDisabledError` unless deployment sets `runtime.enable_unsafe_shell = true` (default `false`). Compile-time `unsafe-shell-disabled` tier-1 catches at authoring.
|
|
454
461
|
- Audit-visible at every fire.
|
|
462
|
+
- Bash's `$(command)` and arithmetic `$((expr))` pass through to bash without escape because skillscript's substitution is braced (`${VAR}`).
|
|
463
|
+
|
|
464
|
+
**TWO security gates, not one — structural shape AND a binary allowlist.** The structural constraint (above) governs *how* a command runs (no shell/metacharacters on the safe path). It is HALF the model. The other half is an **operator-owned binary allowlist (default-deny)** governing *which* binary may run at all:
|
|
465
|
+
|
|
466
|
+
- Every shell op's `argv[0]` (or the first token of `command=`) is checked against the allowlist; a non-allowlisted binary is refused with `ShellBinaryNotAllowedError`, regardless of safe-vs-`unsafe`.
|
|
467
|
+
- **Default is deny-all** — if no allowlist is wired, *every* shell op is refused.
|
|
468
|
+
- Configure via `SKILLSCRIPT_SHELL_ALLOWLIST` env (comma-separated), the `shellAllowlist` field in `skillscript.config.json`, or `bootstrap({ shellAllowlist: [...] })`. The runtime must **restart** to pick up changes. `skillfile shell-audit` scans the corpus and prints the binary union ready to paste.
|
|
469
|
+
- For `unsafe=true`, `bash` itself must be on the allowlist (binary-scope is independent of unsafe-vs-safe).
|
|
470
|
+
- The current allowlist is reported by `runtime_capabilities.shellExecution.allowlist`.
|
|
471
|
+
- **This is an operator boundary the skill author cannot escape** — not via `unsafe`, not via any in-skill mechanism. It scopes *which binary*, not *what the binary does*: allowlisting a powerful authenticated CLI (e.g. `gh`) grants its whole surface, so wrap powerful binaries to least-privilege (a read-only wrapper script on the allowlist instead of the raw binary) before allowlisting.
|
|
455
472
|
|
|
456
|
-
|
|
473
|
+
**All three forms honor the universal op-level kwargs below**, including `(fallback:)` — the fallback fires on op throw OR empty stdout.
|
|
457
474
|
|
|
458
475
|
**Pipes need unsafe; sandboxed multi-call + temp file is the unsafe-free alternative.** A pipe (`curl ... | jq ...`) is a shell metacharacter and requires `unsafe=true`. To compute-in-tools without unsafe, split into sequential sandboxed calls sharing a temp file: `shell(command="curl -s -o /tmp/x.json ...")` then `shell(command="jq -c '<filter>' /tmp/x.json") -> R`. Sequential sandboxed calls share the runtime's filesystem within an execution. Caveat: a fixed temp path races under concurrent invocation (no built-in uniquifier short of a `mktemp` call) — for a large fetched intermediate, keeping it in an execution-scoped var + filtering exports via `# Returns:` is usually cleaner than either shell form.
|
|
459
476
|
|
|
477
|
+
**The `'${VAR}'` quote trap — works in test, breaks on edge input.** Because the structural tokenizer is quote-aware, `shell(command="say -v Jamie '${TEXT}'")` DOES pass a simple multi-word value as one argument — it works for `TEXT="Perry here now"`. That is the trap: it's fragile on the *content* of the substituted value. If `TEXT` contains a quote character (`Jamie's turn`) the quote-matching drifts and the arg breaks — and it fails silently, only on certain inputs, after the pattern already "worked" in testing. Tier-2 lint `shell-quoted-var-in-command` flags the `'${VAR}'`-in-`command=` pattern for exactly this reason and points at `argv=[...]`. Three safe ways to pass an arg that may contain whitespace or quote characters:
|
|
478
|
+
- **`shell(argv=[...])` (preferred):** explicit-token list (form 2 above) — no tokenizer touches the value; the safest because there is no shell and no quote-matching at all.
|
|
479
|
+
- **File-roundtrip:** `file_write(path="/tmp/x.txt", content="${TEXT}")` then `shell(command="say -f /tmp/x.txt")` — the binary reads the value off disk via its own file-input flag; no tokenizer involved. Good when the binary has a file-input mode.
|
|
480
|
+
- **`unsafe=true` + `${TEXT|shell}`:** bash quoting via the POSIX-escape filter; tier-2 lint flags it; injection surface remains if `TEXT` is untrusted.
|
|
481
|
+
|
|
460
482
|
### `file_read` / `file_write` — file I/O
|
|
461
483
|
|
|
462
484
|
```
|
|
@@ -546,7 +568,7 @@ Typed-contract ops (`data_*`, `skill_*`, `json_parse`, `llm`) auto-wire from the
|
|
|
546
568
|
|
|
547
569
|
### Universal op-level kwargs
|
|
548
570
|
|
|
549
|
-
Four surfaces apply to every `$` dispatch regardless of tool. The runtime intercepts these before forwarding the remaining kwargs to the connector.
|
|
571
|
+
Four surfaces apply to every `$` dispatch and to `shell()` (notably `(fallback:)`), regardless of tool. The runtime intercepts these before forwarding the remaining kwargs to the connector.
|
|
550
572
|
|
|
551
573
|
**`timeout=N`** — Per-op timeout in **seconds**. Integer literal or `${VAR}` ref. Resolution chain (most-specific wins):
|
|
552
574
|
|
|
@@ -566,13 +588,14 @@ $ amp.amp_olsen_task task_type="scan" timeout=120 -> DISPATCH
|
|
|
566
588
|
$ data_write content="${REPORT}" tags=["oncall"] approved="morning roundup" -> ACK
|
|
567
589
|
```
|
|
568
590
|
|
|
569
|
-
**`(fallback: "value")` trailer** — Fires on dispatch throw OR empty bound value (empty string after trim, empty array, null/undefined). Coerce-on-bind: the fallback value binds to the output var transparently, downstream targets need no conditional. Permissive value parsing — bare identifiers, quoted strings, bracketed array literals all accepted.
|
|
591
|
+
**`(fallback: "value")` trailer** — Fires on dispatch throw OR empty bound value (empty string after trim, empty array, null/undefined). Honored on `$` dispatch and on `shell()` (fires on shell op throw or empty stdout). Coerce-on-bind: the fallback value binds to the output var transparently, downstream targets need no conditional. Permissive value parsing — bare identifiers, quoted strings, bracketed array literals all accepted. A fired fallback is recorded in `result.fallbacks[]`.
|
|
570
592
|
|
|
571
593
|
Note: an envelope object like `{items: []}` is a non-empty object and does NOT trigger the fallback even though its contained array is empty. To handle envelope-empty downstream, test the contained collection (`if ${R.items|length} == "0":`) or apply a filter (`${R.items|fallback:[]}`).
|
|
572
594
|
|
|
573
595
|
```
|
|
574
596
|
$ llm prompt="Classify: ${INPUT}" -> VERDICT (fallback: "unknown")
|
|
575
597
|
$ amp.amp_query_memories query="${TOPIC}" -> RESULTS (fallback: [])
|
|
598
|
+
shell(argv=["gh","pr","list","--repo","acme/foo"]) -> PRS (fallback: "No open PRs")
|
|
576
599
|
$ ticketing.search query="..." -> ISSUES (fallback: "search-unavailable")
|
|
577
600
|
```
|
|
578
601
|
|
|
@@ -657,7 +680,7 @@ Tool args are unconstrained `key=value` pairs — the connector forwards them to
|
|
|
657
680
|
|
|
658
681
|
See the adopter playbook for the substrate config reference + the full Case 2 tradeoff.
|
|
659
682
|
|
|
660
|
-
**Discovery surface.** `runtime_capabilities` (MCP tool) exposes the registered substrate state. Every entry across all four substrate slots (SkillStore / DataStore / LocalModel / McpConnector) carries its instance `manifest()` payload alongside the static features. Three observable states per entry: working `manifest:{...}`, runtime failure `manifest:null, manifest_error:"..."`, structural absence `manifest:null, manifest_unsupported:true` (AgentConnector only — the contract has no `manifest()` method). The bridge `wraps` convention re-exposes the underlying substrate's full manifest, so adopters reading the discovery surface see the full bound state without traversing multiple entries.
|
|
683
|
+
**Discovery surface.** `runtime_capabilities` (MCP tool) exposes the registered substrate state. Every entry across all four substrate slots (SkillStore / DataStore / LocalModel / McpConnector) carries its instance `manifest()` payload alongside the static features. Three observable states per entry: working `manifest:{...}`, runtime failure `manifest:null, manifest_error:"..."`, structural absence `manifest:null, manifest_unsupported:true` (AgentConnector only — the contract has no `manifest()` method). The bridge `wraps` convention re-exposes the underlying substrate's full manifest, so adopters reading the discovery surface see the full bound state without traversing multiple entries. The `shellExecution` entry reports the operator shell allowlist + mode.
|
|
661
684
|
|
|
662
685
|
Connector entries also surface `features` declarations (`supports_identity_propagation`, `supports_streaming_responses`, `supports_batch`). Capability flags that span multiple layers — `supports_identity_propagation` requires both the connector's ctx-honoring AND the substrate's per-identity scope honoring — gate via `RuntimeCapabilitiesConformance` auto-coverage: declaring a feature flag true requires the adopter to wire both Level-1 (substrate-independent: ctx reaches transport) and Level-2 (substrate-coupled: distinct identities yield distinct observable scopes) probes via `flagProbes`. Missing probes fail the gate before runtime accepts the flag as true. This is the structural close that prevents the discipline-only-contract pattern (capability claim without honoring impl) from recurring at the capability-flag surface.
|
|
663
686
|
|
|
@@ -667,7 +690,7 @@ Connector entries also surface `features` declarations (`supports_identity_propa
|
|
|
667
690
|
|
|
668
691
|
## Per-op gating
|
|
669
692
|
|
|
670
|
-
|
|
693
|
+
Two independent authorization layers govern a shell op: **(0) the binary allowlist** — can this binary run at all (operator-owned, default-deny; see the shell section above; refusal is `ShellBinaryNotAllowedError`) — and **(1) the mutation gate** below. The allowlist is checked first and the author cannot bypass it. Beyond shell, mutation ops require an authorization signal. The signal is per-op, not a mode binary.
|
|
671
694
|
|
|
672
695
|
**Mutation-classified ops:**
|
|
673
696
|
- `file_write(...)` (runtime-intrinsic)
|
|
@@ -678,7 +701,7 @@ Mutation ops require an authorization signal. The signal is per-op, not a mode b
|
|
|
678
701
|
|
|
679
702
|
**Read-only ops (always allowed, no authorization needed):**
|
|
680
703
|
- `file_read`, `emit`, `notify`, `inline`, `execute_skill`
|
|
681
|
-
- `shell(command=...)` with read-only verb
|
|
704
|
+
- `shell(command=...)` with read-only verb (still subject to the binary allowlist)
|
|
682
705
|
- `$ <connector>.<tool>` against tools declared `mutating: false` (or unspecified, default false for query-shaped tools)
|
|
683
706
|
- `$set`, `$append`
|
|
684
707
|
|
|
@@ -718,7 +741,7 @@ deliver:
|
|
|
718
741
|
| Runtime-intrinsic | `notify` | `notify(agent="...", [message=...], [event_type=...], [correlation_id=...]) -> ACK` | optional |
|
|
719
742
|
| Runtime-intrinsic | `inline` | `inline(skill="<name>")` | none (compile-time) |
|
|
720
743
|
| Runtime-intrinsic | `execute_skill` | `execute_skill(skill_name="...", inputs={...}) -> R` | optional |
|
|
721
|
-
| Runtime-intrinsic | `shell` | `shell(command="...", [unsafe=true], [approved="..."]) -> R` | optional |
|
|
744
|
+
| Runtime-intrinsic | `shell` | `shell(command="...", [unsafe=true], [approved="..."]) [-> R] [(fallback: "...")]` or `shell(argv=[...], [approved="..."]) [-> R] [(fallback: "...")]` (mutually exclusive forms; binary allowlist applies to both) | optional |
|
|
722
745
|
| Runtime-intrinsic | `file_read` | `file_read(path="...") -> R` | required |
|
|
723
746
|
| Runtime-intrinsic | `file_write` | `file_write(path="...", content="...", [approved="..."])` | none |
|
|
724
747
|
| External MCP — substrate-specific | `$ <connector>.<tool>` | `$ <connector>.<tool> kwarg=value, ... [timeout=N] [approved="..."] [-> R] [(fallback: "...")]` | optional |
|
|
@@ -863,7 +886,7 @@ Pipe filters apply transforms to resolved variables before substitution. Syntax:
|
|
|
863
886
|
| `trim` | Whitespace trim | `${VERDICT|trim}` for `"urgent\n"` | `urgent` |
|
|
864
887
|
| `length` | Count of items (array) or characters (string) | `${ITEMS|length}` for `["a","b","c"]` | `3` |
|
|
865
888
|
| `contains:"X"` | Boolean: type-aware substring / element membership | `${MSG|contains:"urgent"}` for `"Yes, urgent"` | `true` |
|
|
866
|
-
| `fallback:"X"` | Coalesce on missing/undefined ref | `${VAR.missing|fallback:"-"}` | `-` |
|
|
889
|
+
| `fallback:"X"` | Coalesce on missing/undefined/empty ref | `${VAR.missing|fallback:"-"}` | `-` |
|
|
867
890
|
| `isodate` | Epoch seconds → ISO-8601 timestamp | `${EPOCH|isodate}` for `1779660000` | `2026-05-24T22:00:00.000Z` |
|
|
868
891
|
|
|
869
892
|
### `length` semantics
|
|
@@ -915,16 +938,17 @@ Empty-string match: `${VAR|contains:""}` returns `true` if VAR is bound. Documen
|
|
|
915
938
|
|
|
916
939
|
### `fallback:"X"` semantics
|
|
917
940
|
|
|
918
|
-
Coalesce-on-missing. Emits the literal string `X` when the ref resolves to missing/null/undefined. Strict-by-default semantics preserved everywhere else; `|fallback:` is the explicit opt-out at the call site.
|
|
941
|
+
Coalesce-on-empty-or-missing. Emits the literal string `X` when the ref resolves to missing/null/undefined OR to an empty string. Strict-by-default semantics preserved everywhere else; `|fallback:` is the explicit opt-out at the call site.
|
|
919
942
|
|
|
920
943
|
```
|
|
921
944
|
emit:
|
|
922
|
-
emit(text="present: ${PRESENT|fallback:\"missing\"}") # → "hello" (PRESENT is bound)
|
|
923
|
-
emit(text="missing: ${NOT_DECLARED|fallback:\"-\"}") # → "-" (NOT_DECLARED isn't)
|
|
945
|
+
emit(text="present: ${PRESENT|fallback:\"missing\"}") # → "hello" (PRESENT is bound and non-empty)
|
|
946
|
+
emit(text="missing: ${NOT_DECLARED|fallback:\"-\"}") # → "-" (NOT_DECLARED isn't declared)
|
|
947
|
+
emit(text="empty: ${BOUND_EMPTY|fallback:\"-\"}") # → "-" (bound to "")
|
|
924
948
|
emit(text="nested: ${ISSUE.customFields.Assignee|fallback:\"unassigned\"}")
|
|
925
949
|
```
|
|
926
950
|
|
|
927
|
-
**Why filter-shape, not ref-level `(fallback:)`.** Op-level `(fallback: ...)` exists on `$` dispatch for **error recovery** (
|
|
951
|
+
**Why filter-shape, not ref-level `(fallback:)`.** Op-level `(fallback: ...)` exists on `$` dispatch and `shell()` for **error/empty recovery** (the op ran, then failed or returned empty). Ref-level `|fallback:` is **coalesce** (the lookup itself found nothing — missing, null, or empty). They rhyme but are adjacent concepts. The filter-chain attachment keeps composition clean (`${VAR|json_parse|fallback:"-"}` works as a chain step) and the vocabulary alignment with op-level `(fallback:)` lets cold authors learn "fallback" as the universal concept while the syntax disambiguates the attachment site.
|
|
928
952
|
|
|
929
953
|
**Closes the missing-field strict-error trap**: `${ISSUE.customFields.Assignee}` against an object without that key threw `UnresolvedVariableError` and aborted whole-render. The filter is the per-ref opt-out.
|
|
930
954
|
|
|
@@ -1422,6 +1446,15 @@ A skill may declare multiple output targets, one per line; each receives the sam
|
|
|
1422
1446
|
# Output: agent: assistant
|
|
1423
1447
|
```
|
|
1424
1448
|
|
|
1449
|
+
## Output target resolution
|
|
1450
|
+
|
|
1451
|
+
For the `agent:` and `template:` kinds, the runtime resolves the target agent_id via a **2-level chain** (first match wins):
|
|
1452
|
+
|
|
1453
|
+
1. **Explicit name** — `# Output: agent: perry` delivers to agent_id `perry`.
|
|
1454
|
+
2. **`${VAR}` compile-time substitution** — `# Output: agent: ${RECIPIENT}` resolves against the resolved inputs map (`# Vars:` defaults, `# Requires:` cascade, caller-supplied `inputs`) at compile time. Only compile-time inputs resolve here — a runtime-bound ref (an op's `-> VAR` output, an ambient ref) passes through verbatim and fails at delivery if still unresolved.
|
|
1455
|
+
|
|
1456
|
+
There is **no** invocation-context inheritance and **no** runtime `default_agent_id` fallback: a skill must name its target explicitly or pass it as an input var. See the Connectors section (AgentConnector) for the full delivery contract behind the resolved id.
|
|
1457
|
+
|
|
1425
1458
|
## Per-kind output value semantics
|
|
1426
1459
|
|
|
1427
1460
|
What each kind consumes as the output, in precedence order:
|
|
@@ -1595,16 +1628,17 @@ Nested `# OnError:` is *not* supported. If `# OnError: degraded-skill` fires and
|
|
|
1595
1628
|
|
|
1596
1629
|
## Layer 3: Op-level fallback values
|
|
1597
1630
|
|
|
1598
|
-
Inline fallback declared on the op line. Used when the call fails or returns empty. Supported on `$` (MCP dispatch) ops with coerce-on-bind semantics.
|
|
1631
|
+
Inline fallback declared on the op line. Used when the call fails or returns empty. Supported on `$` (MCP dispatch) ops and on `shell()` with coerce-on-bind semantics. For `shell()` the fallback fires on op throw OR empty stdout (e.g. `gh pr list` against a repo with no open PRs binds the fallback string).
|
|
1599
1632
|
|
|
1600
1633
|
```
|
|
1601
1634
|
weather:
|
|
1602
1635
|
$ data_read mode=fts query="weather ${LOCATION}" limit=1 -> CURRENT (fallback: "weather unavailable")
|
|
1603
1636
|
$ llm prompt="Summarize: ${CURRENT}" -> SUMMARY (fallback: "summary unavailable")
|
|
1604
1637
|
$ slack.post channel=${CHANNEL} text=${SUMMARY} (fallback: "post failed silently") -> ACK
|
|
1638
|
+
shell(argv=["gh","pr","list","--repo","acme/foo"]) -> PRS (fallback: "No open PRs")
|
|
1605
1639
|
```
|
|
1606
1640
|
|
|
1607
|
-
Same pattern as the `# Requires:` cascade's `(fallback: ...)` syntax — consistent across compile-time (`# Requires:`) and runtime (`$` dispatch).
|
|
1641
|
+
Same pattern as the `# Requires:` cascade's `(fallback: ...)` syntax — consistent across compile-time (`# Requires:`) and runtime (`$` dispatch and `shell()`).
|
|
1608
1642
|
|
|
1609
1643
|
**Fallback value parsing.** Permissive: bare identifiers, quoted strings, and bracketed array literals all accepted. Matches the `# Requires:` cascade convention.
|
|
1610
1644
|
|
|
@@ -1632,7 +1666,7 @@ Open spec question: should `${ERROR}` be ambient inside `else:` blocks (same sha
|
|
|
1632
1666
|
|
|
1633
1667
|
Same idea at every scope:
|
|
1634
1668
|
- Compile-time: `# Requires: ... (fallback: value)`
|
|
1635
|
-
- Runtime op: `$ dispatch ... (fallback: value)`
|
|
1669
|
+
- Runtime op: `$ dispatch ... (fallback: value)` and `shell(...) ... (fallback: value)`
|
|
1636
1670
|
- Runtime target: `else:` block
|
|
1637
1671
|
- Whole skill: `# OnError:` header
|
|
1638
1672
|
|
|
@@ -1640,7 +1674,7 @@ Authors composing complex skills use these in combination — op-level for trans
|
|
|
1640
1674
|
|
|
1641
1675
|
## Connection to runtime observability
|
|
1642
1676
|
|
|
1643
|
-
Per-op error contract is what makes cascading fallbacks work. When `$` returns `isError: true`, the executor throws via `makeOpError` rather than binding the error text to the output var. The throw routes through `else:` / `# OnError:` machinery and surfaces in `result.errors[]` for the scheduler to log. Without this discipline, op-level failures wouldn't propagate to the fallback layers and silent-fail would be the default.
|
|
1677
|
+
Per-op error contract is what makes cascading fallbacks work. When `$` returns `isError: true`, the executor throws via `makeOpError` rather than binding the error text to the output var. The throw routes through `else:` / `# OnError:` machinery and surfaces in `result.errors[]` for the scheduler to log. Without this discipline, op-level failures wouldn't propagate to the fallback layers and silent-fail would be the default. Op-level `(fallback:)` is distinct: it intercepts the throw/empty-result at the op and binds the fallback value rather than propagating — a fired fallback is recorded in `result.fallbacks[]`, not `result.errors[]`.
|
|
1644
1678
|
|
|
1645
1679
|
## Composition — skills calling skills
|
|
1646
1680
|
|
|
@@ -2208,5 +2242,5 @@ Hung dispatches hang the skill without explicit timeout configuration. Lean: ski
|
|
|
2208
2242
|
|
|
2209
2243
|
---
|
|
2210
2244
|
|
|
2211
|
-
*Rendered from `skillscript/skillscript-language-reference` — 2026-06-
|
|
2245
|
+
*Rendered from `skillscript/skillscript-language-reference` — 2026-06-15 12:22 EDT*
|
|
2212
2246
|
*Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Skill: classify-support-ticket
|
|
2
|
-
# Status: Approved v1:
|
|
2
|
+
# Status: Approved v1:ae27d3f9
|
|
3
3
|
# Autonomous: true
|
|
4
4
|
# Description: Read an incoming support ticket and route it: severity-1 tickets get paged to ops-channel, billing tickets get tagged for finance review, everything else gets a draft reply queued for human review
|
|
5
5
|
# Vars: TICKET_TEXT, TICKET_ID
|
|
@@ -14,13 +14,13 @@ severity_check:
|
|
|
14
14
|
|
|
15
15
|
route:
|
|
16
16
|
needs: severity_check
|
|
17
|
-
if ${CATEGORY|
|
|
17
|
+
if ${CATEGORY|contains:"sev-1"}:
|
|
18
18
|
emit(text="PAGE: sev-1 ticket ${TICKET_ID} - ${TICKET_TEXT}")
|
|
19
19
|
$ data_write content="sev-1 ticket ${TICKET_ID}: ${TICKET_TEXT}" tags=["support","sev-1","page"]
|
|
20
|
-
elif ${IS_SEV1|
|
|
20
|
+
elif ${IS_SEV1|contains:"yes"}:
|
|
21
21
|
emit(text="PAGE: classifier said ${CATEGORY|trim} but severity-check flagged this as sev-1: ${TICKET_ID}")
|
|
22
22
|
$ data_write content="sev-1 escalation ${TICKET_ID}: category=${CATEGORY|trim} but severity-check=yes" tags=["support","sev-1","disagreement"]
|
|
23
|
-
elif ${CATEGORY|
|
|
23
|
+
elif ${CATEGORY|contains:"billing"}:
|
|
24
24
|
$set TAG_FOR = "finance"
|
|
25
25
|
emit(text="Tagged for ${TAG_FOR} review: ${TICKET_ID}")
|
|
26
26
|
$ data_write content="billing ticket ${TICKET_ID}: ${TICKET_TEXT}" tags=["support","billing"]
|