skillscript-runtime 0.33.0 → 0.34.0

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.
@@ -6,7 +6,7 @@ mode: wide
6
6
 
7
7
  Canonical language reference for skillscript. Audience: skill authors (human + agent). Specifies what is valid syntax, what behavior to expect at compile + runtime, and what is currently pending implementation.
8
8
 
9
- Implementation state is cross-referenced to commit hashes; pending items mark v2/v3 work.
9
+ It describes the current runtime; release history lives in the CHANGELOG. Items not yet implemented are called out in the relevant sections.
10
10
 
11
11
 
12
12
  ## Overview & language model
@@ -29,11 +29,23 @@ Skillscript's job is to express this pipeline declaratively. When there is an ag
29
29
 
30
30
  ## Two execution paths
31
31
 
32
- **Runtime-mediated** — the interpreter walks ops and dispatches them directly through configured connectors. Used for autonomous fires (cron- or event-triggered). Safety boundary is the connector config + per-op gating (see Ops Reference).
32
+ The two paths differ by **who executes the ops** — the runtime, or an agent *not* by who invokes the skill. Getting this backwards is the classic trap: an agent calling a skill mid-conversation still gets deterministic runtime execution. Determinism is a property of the execution path, not of the caller's context.
33
33
 
34
- **Agent-mediated** — the compiler renders the skill as a prompt; an agent reads the prompt and executes ops through its own tools. Used when an agent invokes a skill mid-conversation. Safety boundary is the agent's harness tool permissions.
34
+ **Runtime-mediated** — the interpreter walks the ops and dispatches them through configured connectors, returning a completed, deterministic result. This is *every actual execution*, regardless of caller: autonomous cron/event fires, the CLI execute command, `execute_skill` over MCP (an agent invoking a stored skill mid-conversation — the common case), in-skill `$ execute_skill` composition, and `inline(skill=...)`. If a skill *runs*, it runs here. Safety boundary is the connector config + per-op gating (see Ops Reference).
35
35
 
36
- The language is identical in both paths. The execution model is a deployment-time + invocation-time decision.
36
+ **Agent-mediated** *not* an execution path in the "runs the ops" sense. It's the **compile/preview** path: `compile_skill` renders the skill as a prompt (no side effects, nothing executed), and an agent may then read that prompt and carry out the steps through its *own* tools. Determinism is not guaranteed here, because the runtime never touches the ops. Safety boundary is the agent's harness tool permissions.
37
+
38
+ **Neither of those is `# Output: agent:`.** That's a *delivery* target — a runtime-executed (deterministic) skill handing its rendered output to an agent for its next turn. "Agent" there names who *receives* the result, not who executes the ops.
39
+
40
+ ### Which call gets which path
41
+
42
+ | Call | Path | Result |
43
+ |---|---|---|
44
+ | `execute_skill({name})` (MCP), CLI execute, cron/event fire, `$ execute_skill`, `inline(skill=…)` | Runtime-mediated | The runtime walks the ops and returns the finished result. **Deterministic.** |
45
+ | `compile_skill({name})` | Agent-mediated | Returns the rendered plan/prompt for inspection. No side effects, nothing executed. |
46
+ | `# Output: agent: <name>` | Runtime-mediated + push delivery | A runtime-executed skill pushes its rendered output to an agent. "Agent" is the delivery target, not the executor. |
47
+
48
+ The language is identical across paths. Which path applies is a deployment-time + invocation-time decision — but **execution determinism is a property of runtime dispatch (`execute_skill` and friends), never of the caller's context.** That is the guarantee the runtime makes, and the reason `execute_skill` mid-conversation is a deterministic replacement for ad-hoc agent tool-sequencing.
37
49
 
38
50
  ## Output production and delivery channels
39
51
 
@@ -301,9 +313,9 @@ Closed list of language-intrinsic ops the runtime knows directly. Each is a func
301
313
  | `emit` | `emit(text="...")` | none | Append to the skill's emission stream; consumed by the configured `# Output:` delivery channel. |
302
314
  | `notify` | `notify(agent="...", message="...", [event_type=...], [correlation_id=...]) -> ACK` | optional | Mid-skill agent alert; synchronous send via configured AgentConnector. |
303
315
  | `inline` | `inline(skill="<data-skill-name>")` | none | Compile-time inline of an Approved `# Type: data` skill. Resolves at compile, records `content_hash` in provenance. |
304
- | `execute_skill` | `execute_skill(name="...", inputs=\{...}) -> R` | optional | Composition primitive. Runtime-resolved. `skill_name=` accepted as back-compat alias. See Composition section. |
316
+ | `execute_skill` | `execute_skill(name="...", inputs={...}) -> R` | optional | Composition primitive. Runtime-resolved. `skill_name=` accepted as back-compat alias. See Composition section. |
305
317
  | `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. |
306
- | `file_read` | `file_read(path="...") -> R` | required | Read a file at `path`; binds string contents. Optional `encoding="utf8"\|"base64"` kwarg (default `utf8`). |
318
+ | `file_read` | `file_read(path="...") -> R` | required | Read a file at `path`; binds string contents. Optional `encoding="utf8"|"base64"` kwarg (default `utf8`). |
307
319
  | `file_write` | `file_write(path="...", content="...")` | none | Write `content` to `path`. `mkdir -p` semantics for parent directories. Mutation-classified. |
308
320
 
309
321
  **Unknown op name** → tier-1 lint `unknown-runtime-op` with remediation pointing at MCP dispatch: "if this is an external tool, use `$ <connector>.<tool> args -> R`."
@@ -517,7 +529,7 @@ $ youtrack.run_report report_id="INFRA-weekly" timeout=120 -> DISPATCH
517
529
  $ data_write content="${REPORT}" tags=["oncall"] approved="morning roundup" -> ACK
518
530
  ```
519
531
 
520
- **`(fallback: "value")` trailer** — Uniform as of v0.27.0: fires on ANY op failure — a dispatch throw (including a `timeout=N` expiry), an empty bound value (empty string after trim, empty array, null/undefined), OR a raised throw from an `execute_skill` child or a `$ json_parse` off-shape input. 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[]` (with its `.reason`).
532
+ **`(fallback: "value")` trailer** — Uniform: fires on ANY op failure — a dispatch throw (including a `timeout=N` expiry), an empty bound value (empty string after trim, empty array, null/undefined), OR a raised throw from an `execute_skill` child or a `$ json_parse` off-shape input. 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[]` (with its `.reason`).
521
533
 
522
534
  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:[]}`).
523
535
 
@@ -664,14 +676,14 @@ deliver:
664
676
 
665
677
  | Class | Op | Shape | Binding |
666
678
  |---|---|---|---|
667
- | Mutation | `$set` | `$set NAME = value` (with `$\{VAR}` interpolation at bind) | NAME (no arrow) |
679
+ | Mutation | `$set` | `$set NAME = value` (with `${VAR}` interpolation at bind) | NAME (no arrow) |
668
680
  | Mutation | `$append` | `$append VAR <value>` (type-dispatched: list element / string concat) | VAR (no arrow) |
669
681
  | Runtime-intrinsic | `emit` | `emit(text="...")` or `emit(text="""...""")` for multi-line | none |
670
682
  | Runtime-intrinsic | `notify` | `notify(agent="...", [message=...], [event_type=...], [correlation_id=...]) -> ACK` | optional |
671
683
  | Runtime-intrinsic | `inline` | `inline(skill="<name>")` | none (compile-time) |
672
- | Runtime-intrinsic | `execute_skill` | `execute_skill(name="...", inputs=\{...}) -> R` (`skill_name=` back-compat alias) | optional |
684
+ | Runtime-intrinsic | `execute_skill` | `execute_skill(name="...", inputs={...}) -> R` (`skill_name=` back-compat alias) | optional |
673
685
  | 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 |
674
- | Runtime-intrinsic | `file_read` | `file_read(path="...", [encoding="utf8"\|"base64"]) -> R` | required |
686
+ | Runtime-intrinsic | `file_read` | `file_read(path="...", [encoding="utf8"|"base64"]) -> R` | required |
675
687
  | Runtime-intrinsic | `file_write` | `file_write(path="...", content="...", [approved="..."])` | none |
676
688
  | External MCP — substrate-specific | `$ <connector>.<tool>` | `$ <connector>.<tool> kwarg=value, ... [timeout=N] [approved="..."] [-> R] [(fallback: "...")]` | optional |
677
689
  | External MCP — typed-contract | `$ <tool>` | `$ <tool> kwarg=value, ... [timeout=N] [approved="..."] [-> R] [(fallback: "...")]` (typed-contract ops only: `data_*`, `skill_*`, `json_parse`, `llm`) | optional |
@@ -698,13 +710,13 @@ Injected automatically at runtime; never declared by the author.
698
710
 
699
711
  | Var | Value |
700
712
  |-----|-------|
701
- | `$\{NOW}` | ISO-8601 timestamp at op-dispatch time |
702
- | `$\{USER}` | The configured user identity |
703
- | `$\{SESSION_CONTEXT}` | Current session-scope context (project/entity/etc., substrate-defined) |
704
- | `$\{TRIGGER_TYPE}` | What event fired this skill |
705
- | `$\{TRIGGER_PAYLOAD}` | Event-specific data |
706
- | `$\{EVENT.*}` | Event-payload fields populated by the trigger source |
707
- | `$\{ERROR_CONTEXT}` | Inside a target's `else:` error-handler: kind + message + target of the failure. |
713
+ | `${NOW}` | ISO-8601 timestamp at op-dispatch time |
714
+ | `${USER}` | The configured user identity |
715
+ | `${SESSION_CONTEXT}` | Current session-scope context (project/entity/etc., substrate-defined) |
716
+ | `${TRIGGER_TYPE}` | What event fired this skill |
717
+ | `${TRIGGER_PAYLOAD}` | Event-specific data |
718
+ | `${EVENT.*}` | Event-payload fields populated by the trigger source |
719
+ | `${ERROR_CONTEXT}` | Inside a target's `else:` error-handler: kind + message + target of the failure. |
708
720
 
709
721
  Iterator vars from `foreach` and output bindings from runtime-intrinsic / MCP-dispatch ops also pass through ambient at compile time; the runtime substitutes them per iteration / per op completion.
710
722
 
@@ -886,14 +898,14 @@ Pipe filters apply transforms to resolved variables before substitution. Syntax:
886
898
 
887
899
  | Filter | Effect | Example | Output |
888
900
  |--------|--------|---------|--------|
889
- | `url` | `encodeURIComponent(value)` | `$\{location\|url}` for `"Asheville, NC"` | `Asheville%2C%20NC` |
890
- | `shell` | POSIX single-quote escape with outer quotes | `$\{arg\|shell}` for `it's safe` | `'it'\''s safe'` |
891
- | `json` | `JSON.stringify(value)` | `$\{payload\|json}` for `\{k:"v"}` | `"\{\"k\":\"v\"}"` |
892
- | `trim` | Whitespace trim | `$\{VERDICT\|trim}` for `"urgent\n"` | `urgent` |
893
- | `length` | Count of items (array) or characters (string) | `$\{ITEMS\|length}` for `["a","b","c"]` | `3` |
894
- | `contains:"X"` | Boolean: type-aware substring / element membership | `$\{MSG\|contains:"urgent"}` for `"Yes, urgent"` | `true` |
895
- | `fallback:"X"` | Coalesce on missing/undefined/empty ref | `$\{VAR.missing\|fallback:"-"}` | `-` |
896
- | `isodate` | Epoch seconds → ISO-8601 timestamp | `$\{EPOCH\|isodate}` for `1779660000` | `2026-05-24T22:00:00.000Z` |
901
+ | `url` | `encodeURIComponent(value)` | `${location|url}` for `"Asheville, NC"` | `Asheville%2C%20NC` |
902
+ | `shell` | POSIX single-quote escape with outer quotes | `${arg|shell}` for `it's safe` | `'it'\''s safe'` |
903
+ | `json` | `JSON.stringify(value)` | `${payload|json}` for `{k:"v"}` | `"{\"k\":\"v\"}"` |
904
+ | `trim` | Whitespace trim | `${VERDICT|trim}` for `"urgent\n"` | `urgent` |
905
+ | `length` | Count of items (array) or characters (string) | `${ITEMS|length}` for `["a","b","c"]` | `3` |
906
+ | `contains:"X"` | Boolean: type-aware substring / element membership | `${MSG|contains:"urgent"}` for `"Yes, urgent"` | `true` |
907
+ | `fallback:"X"` | Coalesce on missing/undefined/empty ref | `${VAR.missing|fallback:"-"}` | `-` |
908
+ | `isodate` | Epoch seconds → ISO-8601 timestamp | `${EPOCH|isodate}` for `1779660000` | `2026-05-24T22:00:00.000Z` |
897
909
 
898
910
  ### `length` semantics
899
911
 
@@ -954,7 +966,7 @@ emit:
954
966
  emit(text="nested: ${ISSUE.customFields.Assignee|fallback:\"unassigned\"}")
955
967
  ```
956
968
 
957
- **Order-independent in a filter chain (v0.26.2+).** A `|fallback` anywhere in the chain rescues an unresolved base — `${x|trim|fallback:"d"}` and `${x|fallback:"d"|trim}` both degrade an unresolved `x` to `"d"`. Nuance: it rescues a genuinely-unresolved/missing base; a **present-but-empty value still flows through the other filters**, so `${x|length|fallback:"0"}` on `x=" "` returns `"2"`, not `"0"`. See the Robustness & error containment section for the chain semantics.
969
+ **Order-independent in a filter chain.** A `|fallback` anywhere in the chain rescues an unresolved base — `${x|trim|fallback:"d"}` and `${x|fallback:"d"|trim}` both degrade an unresolved `x` to `"d"`. Nuance: it rescues a genuinely-unresolved/missing base; a **present-but-empty value still flows through the other filters**, so `${x|length|fallback:"0"}` on `x=" "` returns `"2"`, not `"0"`. See the Robustness & error containment section for the chain semantics.
958
970
 
959
971
  **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.
960
972
 
@@ -1037,7 +1049,7 @@ Several filters are planned but not yet shipped:
1037
1049
  | `summary` | One-line abbreviation | Compress for human-facing emissions |
1038
1050
  | `pluck:<field>` | Project array of objects to array of field values | Paired with `in`/`not in` for dedup-by-id workflows |
1039
1051
  | `join:"<sep>"` | List → string with separator | Filter-shape alternative to string `$append`; reconsider if filter-chain demand surfaces |
1040
- | `isodate_ms` | Epoch ms → ISO-8601 | Companion to `\|isodate`; defer until demand |
1052
+ | `isodate_ms` | Epoch ms → ISO-8601 | Companion to `|isodate`; defer until demand |
1041
1053
 
1042
1054
  `pluck` is the highest-priority remaining filter — it closes the structural-dedup gap for skills that iterate retrieval results and want to exclude already-seen items by ID without manual comparison loops.
1043
1055
 
@@ -1261,7 +1273,7 @@ run:
1261
1273
  foreach D in ${DS}:
1262
1274
  ...
1263
1275
  ```
1264
- An empty list (`DOMAINS='[]'`, or an empty/whitespace string) iterates zero times (v0.26.2+). The comma-split affordance is for `# Vars:` DEFAULTS only, not runtime inputs — there is no `|split` filter (tracked as a DX enhancement).
1276
+ An empty list (`DOMAINS='[]'`, or an empty/whitespace string) iterates zero times. The comma-split affordance is for `# Vars:` DEFAULTS only, not runtime inputs — there is no `|split` filter (tracked as a DX enhancement).
1265
1277
 
1266
1278
  ## Triggers — # Triggers: header, declarative + imperative registration, source types
1267
1279
 
@@ -1798,6 +1810,14 @@ interface McpDispatchCtx {
1798
1810
 
1799
1811
  Hard-coupling skills to specific substrates would make information-flow decisions infrastructural rather than skill-authored, defeating the point of skills as the agent's programming language. The connector layer is what lets the same skill body run against substrate A today and run against substrate B tomorrow without rewriting.
1800
1812
 
1813
+ ## Writing a non-MCP connector — "MCP" names the verb, not the wire
1814
+
1815
+ `McpConnector` is the dispatch surface for `$ connector.tool` ops; the name refers to the skill-facing verb, **not** a wire-protocol requirement. The contract is just `call(toolName, args, ctxOverrides?)` plus an optional `describeTools()` — which surfaces the connector's tool set (inputSchema per tool) for authoring and lint. MCP JSON-RPC framing is `HttpMcpConnector`'s implementation detail, not part of the interface.
1816
+
1817
+ So a backend that speaks plain REST is first-class: `class RestConnector implements McpConnector` whose `call()` maps `tool + kwargs` to an HTTPS request (path-templating, query-vs-body routing, auth header) gets the full typed-contract surface — closed tool set for lint, per-tool kwarg validation, `$`-prefix state-affecting gating — with zero MCP framing. MCP and REST connectors coexist freely in `connectors.json`; a single skill body can use `$ gmail.send` (MCP) and `$ tickets.create` (REST) side by side.
1818
+
1819
+ Full contract spec + a runnable worked example: see `connector-contract-reference.md` (McpConnector section) and `examples/connectors/RestConnector/`.
1820
+
1801
1821
  ## Lifecycle and status — # Status: header, three canonical states (Draft / Approved / Disabled), compile + runtime enforcement
1802
1822
 
1803
1823
  Skillscripts carry an explicit lifecycle state via the `# Status:` header. The compiler and runtime enforce it — a Disabled skill cannot fire under any path, and (in secured mode) an unapproved skill cannot perform effectful ops under any path.
@@ -1861,13 +1881,29 @@ Test, Deployed, and Deprecated were considered and deferred — today's Draft/Ap
1861
1881
 
1862
1882
  Lifecycle states are the language's operational-safety answer. A Disabled skill can't fire even if every author forgets it's broken; in secured mode, an Approved skill can't fire if its body was tampered post-signature, and an agent can't approve its own work. The constraint IS the safety story, here as elsewhere.
1863
1883
 
1884
+ ## Frontmatter tags — # Tags: classification metadata
1885
+
1886
+ `# Tags:` is optional frontmatter for **skill classification** — a comma-separated list of free-form labels used to organize and filter skills. It has no effect on execution, dispatch, or approval.
1887
+
1888
+ ```
1889
+ # Tags: skill-classification, email, deterministic
1890
+ ```
1891
+
1892
+ **Parsing.** The comma-list parses to a `tags: string[]` on the parsed skill. Omitted → empty list (`[]`), never null; every skill carries a `tags` array.
1893
+
1894
+ **Approval-neutral.** Tags are excluded from the approval signing hash — the signature is computed over the canonicalized body with the `# Tags:` line stripped, the same treatment as `# Status:`. Editing a skill's tags therefore does **not** invalidate its approval or force re-approval. Nothing security-relevant reads tags; they are organizational metadata only.
1895
+
1896
+ **Runtime-derived, surfaced on the contract.** Like `# Description:` and `# Vars:`, the runtime derives tags from parsed frontmatter, so a `SkillStore` that omits a dedicated tags field needs no change. Tags surface as `SkillMeta.tags` and on the `skill_list` / `skill_preflight` payloads (empty `[]` when untagged). Authoring surfaces (e.g. the dashboard) facet the skills list by tag — filter chips plus per-row pills.
1897
+
1898
+ **When to use.** Group related skills for discovery in a growing library (by domain, by delivery kind, by team). Tags complement the `# Description:` trigger-condition discipline: description drives *invocation selection*, tags drive *organization and filtering*.
1899
+
1864
1900
  ## Error handling — else: blocks and op-level (fallback:) values
1865
1901
 
1866
1902
  Skillscript has no try/catch — error handling is authored. Two runtime mechanisms (local to global) plus a structural discipline that prevents failure outright.
1867
1903
 
1868
1904
  ## Layer 1: Target-level `else:` block — the throw-container
1869
1905
 
1870
- Runs if any op in the target's primary body throws. Local to the failing target; downstream targets that depend on it can still proceed with whatever the `else:` branch produced. `${ERROR_CONTEXT}` (`.kind` / `.message` / `.target`) is available inside it. This is the way to contain a raised throw — including an `execute_skill` child-throw and a `$ json_parse` on malformed input (both verified v0.27.0).
1906
+ Runs if any op in the target's primary body throws. Local to the failing target; downstream targets that depend on it can still proceed with whatever the `else:` branch produced. `${ERROR_CONTEXT}` (`.kind` / `.message` / `.target`) is available inside it. This is the way to contain a raised throw — including an `execute_skill` child-throw and a `$ json_parse` on malformed input.
1871
1907
 
1872
1908
  ```
1873
1909
  fetch:
@@ -1879,9 +1915,9 @@ else:
1879
1915
 
1880
1916
  Distinguished from conditional `else:` (which appears after an `if:`/`elif:` chain inside a body) by the parser's scope-stack; both can coexist in a target. An `else:` block may not declare its own error handler.
1881
1917
 
1882
- ## Layer 2: Op-level `(fallback:)` value — uniform (v0.27.0+)
1918
+ ## Layer 2: Op-level `(fallback:)` value — uniform
1883
1919
 
1884
- Inline fallback on the op line. As of **v0.27.0 it is uniform: it contains ANY failure of that op** — a raised throw OR an empty/missing result. It works on `$` (MCP dispatch), `shell()`, `file_read`, and — since v0.27.0 — `execute_skill` (child throw) and `$ json_parse` (malformed input). On failure the fallback value binds to the output var via the same path as a success (coerce-on-bind), so downstream sees it transparently and needs no "did this fail?" check. A fired fallback is recorded in `result.fallbacks[].reason` (not `result.errors[]`) — degrade-loud.
1920
+ Inline fallback on the op line. It is **uniform: it contains ANY failure of that op** — a raised throw OR an empty/missing result. It works on `$` (MCP dispatch), `shell()`, `file_read`, `execute_skill` (child throw), and `$ json_parse` (malformed input). On failure the fallback value binds to the output var via the same path as a success (coerce-on-bind), so downstream sees it transparently and needs no "did this fail?" check. A fired fallback is recorded in `result.fallbacks[].reason` (not `result.errors[]`) — degrade-loud.
1885
1921
 
1886
1922
  ```
1887
1923
  weather:
@@ -1910,7 +1946,7 @@ Pre-bind defaults (`$set`) for every var the template/downstream reads, then gat
1910
1946
 
1911
1947
  ## Deprecated: `# OnError:` — parsed but not wired
1912
1948
 
1913
- A skill-level `# OnError: <fallback-skill>` header parses, but it is **not wired in the current runtime: the named fallback never fires.** A skill that relies on it has, in effect, no error handling — so don't use it. Use a target-level `else:` handler (or an op-level `(fallback:)`) instead. Removal of the header is planned for a post-launch release (after 0.30); until then it is inert, not a compile error. To recover at the whole-skill level, wrap the body in a target with an `else:` that dispatches your recovery skill:
1949
+ A skill-level `# OnError: <fallback-skill>` header parses, but it is **not wired in the current runtime: the named fallback never fires.** A skill that relies on it has, in effect, no error handling — so don't use it. Use a target-level `else:` handler (or an op-level `(fallback:)`) instead. The header is inert, not a compile error; removal is planned. To recover at the whole-skill level, wrap the body in a target with an `else:` that dispatches your recovery skill:
1914
1950
 
1915
1951
  ```
1916
1952
  run:
@@ -1930,14 +1966,14 @@ else:
1930
1966
 
1931
1967
  **Skillscript has no try/catch — by design. Robustness is authored, not caught.** An unguarded fallible op that fails aborts the whole target — and in a fan-out, aborts every sibling that hadn't run yet. You contain failures explicitly.
1932
1968
 
1933
- **One uniform rule (v0.27.0+): `(fallback:)` contains ANY op failure.** A `-> VAR (fallback: "<default>")` trailer catches whatever goes wrong with that op — a `$`/`shell` dispatch error, a `shell` spawn-fail/timeout, an empty result, AND a raised throw (`$ json_parse` on off-shape input, `execute_skill` whose child throws). The op degrades to the fallback value and the target continues. (History: before v0.27.0, `execute_skill` and `$ json_parse` were runtime intercepts that threw before the fallback wiring, so `(fallback:)` did NOT catch their throws — you needed `else:` or a structural guard. v0.27.0 made the intercepts consult `op.fallback` like `file_read`, retiring that split. Legacy note for ≤0.26.x authors at the end.)
1969
+ **One uniform rule: `(fallback:)` contains ANY op failure.** A `-> VAR (fallback: "<default>")` trailer catches whatever goes wrong with that op — a `$`/`shell` dispatch error, a `shell` spawn-fail/timeout, an empty result, AND a raised throw (`$ json_parse` on off-shape input, `execute_skill` whose child throws). The op degrades to the fallback value and the target continues.
1934
1970
 
1935
1971
  **The containment tools — pick by how much you want to handle:**
1936
1972
  | Tool | Catches | Scope |
1937
1973
  |---|---|---|
1938
1974
  | `(fallback: "…")` op trailer | ANY failure of that op — dispatch error / spawn-fail / timeout / empty result / raised throw | the single op |
1939
- | `$\{ref\\|fallback:"x"}` filter | a missing / unresolved / empty value at use-time | the single reference |
1940
- | `else:` block | a raised throw anywhere in the target body, WITH error context (`$\{ERROR_CONTEXT.kind/.message/.target}`) | the whole target |
1975
+ | `${ref\|fallback:"x"}` filter | a missing / unresolved / empty value at use-time | the single reference |
1976
+ | `else:` block | a raised throw anywhere in the target body, WITH error context (`${ERROR_CONTEXT.kind/.message/.target}`) | the whole target |
1941
1977
  | structural guard (pre-bind defaults + a `contains`/shape check before the risky op) | PREVENTS the failure — the op isn't reached on bad input, so it can't fail | the risky op |
1942
1978
  | `# OnError: <skill>` | INERT in the current runtime (fallbackSkillExecutor never wired) — do NOT rely on it; prefer `else:` | — |
1943
1979
 
@@ -1958,14 +1994,12 @@ default: fetch
1958
1994
  ```
1959
1995
  Bad input skips the `if`, the default stands, the child never throws — so a parent gather can't be sunk by it (independent of the parent's own fallback).
1960
1996
 
1961
- **Rule 3 — a `fallback` filter anywhere in a chain rescues an unresolved reference** (order-independent, v0.26.2+; also rescues a missing dotted/numeric path on a present object — `${W.a.0.b|fallback:"d"}` when `W` lacks `a` → `"d"`). A chain with NO `fallback` still throws on an unresolved ref — that's your signal to add one. It rescues only genuinely-unresolved refs; a present-but-empty value (`" "`) still flows through the other filters, so `${x|length|fallback:"0"}` on `" "` is `"2"`.
1997
+ **Rule 3 — a `fallback` filter anywhere in a chain rescues an unresolved reference** (order-independent; also rescues a missing dotted/numeric path on a present object — `${W.a.0.b|fallback:"d"}` when `W` lacks `a` → `"d"`). A chain with NO `fallback` still throws on an unresolved ref — that's your signal to add one. It rescues only genuinely-unresolved refs; a present-but-empty value (`" "`) still flows through the other filters, so `${x|length|fallback:"0"}` on `" "` is `"2"`.
1962
1998
 
1963
1999
  **Rule 4 — A body-text output template must not reference a var a fallible step might leave unset.** The template renders after the target runs; an unset var hard-fails the render (`Unresolved variable reference: $(AREA)`) — and that is exactly how a child skill throws OUT to its `execute_skill` parent. Pre-bind every template var to a default before the fallible step (this doubles as the throw-prevention structure in Rule 2), or source each with a `|fallback`.
1964
2000
 
1965
2001
  **Rule 5 — Degrade LOUD, not silent.** A fallback value should be a visible marker ("unavailable", "—", "n/a") — never empty, never a plausible-but-wrong value. A degraded run must be diagnosable: the throw's reason is preserved in `result.fallbacks[].reason`, so a degraded leg stays traceable. Don't silently ship a blank or a fake reading. Clean-degrade beats both a hard abort and a silent lie.
1966
2002
 
1967
- **Legacy note (≤0.26.x runtimes):** before v0.27.0, `(fallback:)` did NOT catch a raised throw from `execute_skill` or `$ json_parse` (they bypassed op.fallback). On an older runtime, contain those two with `else:` or a structural guard — a `(fallback:)` on them is a no-op against a throw. v0.27.0+ unifies it, so this only matters if you must support an old runtime.
1968
-
1969
2003
  ## Composition — skills calling skills
1970
2004
 
1971
2005
  Skillscript supports skill-to-skill composition via the runtime's public composition primitive. A parent skill invokes a child skill, optionally passes inputs, optionally binds the child's result. The runtime threads variable state, propagates errors, and enforces a recursion-depth guard.
@@ -2027,10 +2061,6 @@ default: fetch
2027
2061
 
2028
2062
  **Top-level MCP result is filtered too.** A direct `execute_skill` MCP call of a no-`# Returns:` skill returns `final_vars: {}` — the filter applies to direct invocation, not just child-propagation. Adopters inspecting `final_vars` via the MCP tool see the declared-returns surface, not the full variable dump.
2029
2063
 
2030
- **Lint:**
2031
- - `unknown-returns-ref` (tier-1) — `# Returns: X` where `X` isn't bound anywhere in the skill body. Same shape as undeclared-var, for the export side.
2032
- - `unexported-final-var-access` (tier-2 advisory) — caller accesses `${R.X}` where `X` isn't in the called skill's `# Returns:`. Catches the "forgot to export it" footgun (forward-reference deferred-resolution if the called skill isn't yet stored).
2033
-
2034
2064
  ## Semantics
2035
2065
 
2036
2066
  **Skill resolution.** Missing skills produce a clean structured error (`MissingSkillReferenceError extends OpError`) — the parent's `(fallback: ...)` discipline applies if specified, otherwise a target-level `else:` handler catches it if declared, otherwise the parent fails with the error propagated through.
@@ -2047,7 +2077,7 @@ default: fetch
2047
2077
 
2048
2078
  Two non-obvious behaviors when reading a child's failures from the parent (both confirmed by the v1.0 runtime-semantics test battery):
2049
2079
 
2050
- - **Nested errors do NOT surface at the top level.** When a child op fails — e.g. the recursion-depth guard fires — the structured error nests inside the child's `R.errors`, which nests inside *its* parent's `R.errors`, and so on up the chain. It does **not** bubble to the caller's top-level `result.errors`. So a top-level `errors: []` does **not** mean nothing failed downstream. To detect a child failure, inspect the bound `${R.errors}` (and deeper), or rely on `(fallback: ...)` / `else:` — those fire on the structured error regardless of nesting depth. As of v0.27.0 the op-level `(fallback: ...)` trailer is uniform: it contains a raised `execute_skill` child-throw (recursion-guard fire, missing-skill, or any error nested in the child), so `execute_skill(...) -> R (fallback: "...")` reliably degrades on a child failure regardless of nesting depth, and the fired fallback lands in `result.fallbacks[].reason`.
2080
+ - **Nested errors do NOT surface at the top level.** When a child op fails — e.g. the recursion-depth guard fires — the structured error nests inside the child's `R.errors`, which nests inside *its* parent's `R.errors`, and so on up the chain. It does **not** bubble to the caller's top-level `result.errors`. So a top-level `errors: []` does **not** mean nothing failed downstream. To detect a child failure, inspect the bound `${R.errors}` (and deeper), or rely on `(fallback: ...)` / `else:` — those fire on the structured error regardless of nesting depth. The op-level `(fallback: ...)` trailer is uniform: it contains a raised `execute_skill` child-throw (recursion-guard fire, missing-skill, or any error nested in the child), so `execute_skill(...) -> R (fallback: "...")` reliably degrades on a child failure regardless of nesting depth, and the fired fallback lands in `result.fallbacks[].reason`.
2051
2081
  - **`# Returns: R` is load-bearing for observability.** If the parent binds a child via `-> R` but does not declare `# Returns: R`, the `# Returns:` filter strips `R` from the parent's `final_vars` — and any nested child errors disappear from the parent's MCP-wire response along with it. A composition skill that wants its child's failures observable from *its own* caller must declare the binding (`# Returns: R`) explicitly. Observability of child failure is opt-in, the same way value export is.
2052
2082
 
2053
2083
  ## Forward-reference resolution
@@ -2143,7 +2173,7 @@ call_maybe_missing:
2143
2173
  default: call_maybe_missing
2144
2174
  ```
2145
2175
 
2146
- As of v0.27.0 the `(fallback:)` trailer is uniform, so it contains an `execute_skill` child-throw (missing-skill, recursion-guard fire, or any error raised inside the child) as well as a missing/empty bind — `RESULT` degrades to `"child unavailable"` on any of those, and the fired fallback is recorded in `result.fallbacks[].reason`.
2176
+ The `(fallback:)` trailer is uniform, so it contains an `execute_skill` child-throw (missing-skill, recursion-guard fire, or any error raised inside the child) as well as a missing/empty bind — `RESULT` degrades to `"child unavailable"` on any of those, and the fired fallback is recorded in `result.fallbacks[].reason`.
2147
2177
 
2148
2178
  **TestFlight preview (from the runtime caller, not from inside a skill):**
2149
2179
 
@@ -2167,7 +2197,7 @@ For *data skills* (skills marked `# Type: data`), the compile-time inline primit
2167
2197
  - Treat composition as a real cost. Each `execute_skill()` dispatch incurs the child's full execution time + side effects. Don't compose for trivial cases that could be inlined.
2168
2198
  - Declare `# Returns:` when a caller needs structured access to a child's variables. Leave it off for emit-only skills whose consumers read `.outputs.text` — the default-empty filter keeps scratch from propagating.
2169
2199
  - Want a child's failures visible to your caller? Declare the child binding in `# Returns:` — nested errors are filtered out with the binding otherwise (see Error surfacing in composition), and don't trust a top-level `errors: []`.
2170
- - Pair composition with `(fallback: ...)` when the child skill might fail and the parent has a sensible degraded path. As of v0.27.0 the `(fallback:)` catches a raised child throw, not just a missing bind.
2200
+ - Pair composition with `(fallback: ...)` when the child skill might fail and the parent has a sensible degraded path. The `(fallback:)` catches a raised child throw, not just a missing bind.
2171
2201
  - Use mechanical mode to TestFlight any multi-skill chain before shipping it as a Headless skill on a cron trigger.
2172
2202
  - Forward references work — author sibling skills in any order, validate independently. The tier-2 warning surfaces the deferred-resolution path; runtime catches genuine misses.
2173
2203
  - Recursion is legal but bounded. If your design requires deeper recursion than the configured limit, reshape the workflow — almost always a sign of an iteration that should be expressed as `foreach` rather than recursion.
@@ -2565,5 +2595,5 @@ When any of these primitives ship, the relevant grammar moves into its canonical
2565
2595
 
2566
2596
  ---
2567
2597
 
2568
- *Rendered from `skillscript/skillscript-language-reference` — 2026-07-10 14:34 EDT*
2569
- *Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*
2598
+ *Rendered from `skillscript/skillscript-language-reference` — 2026-07-16 18:49 EDT*
2599
+ *Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*
@@ -9,12 +9,12 @@ Worked examples + fork-me templates for adopter-written connectors. The bundled
9
9
  | `SkillStore` | `FilesystemSkillStore`, `SqliteSkillStore` (in `src/connectors/`) | — | **[SkillStoreTemplate/](./SkillStoreTemplate/)** |
10
10
  | `DataStore` | `SqliteDataStore` (in `src/connectors/`) | — | **[DataStoreTemplate/](./DataStoreTemplate/)** |
11
11
  | `LocalModel` | `OllamaLocalModel` (in `src/connectors/`; opt-in via substrate config) | — | **[LocalModelTemplate/](./LocalModelTemplate/)** |
12
- | `McpConnector` | `RemoteMcpConnector`, `CallbackMcpConnector`, `LocalModelMcpConnector`, `DataStoreMcpConnector`, `SkillStoreMcpConnector` (in `src/connectors/`) | | **[McpConnectorTemplate/](./McpConnectorTemplate/)** |
12
+ | `McpConnector` | `HttpMcpConnector`, `RemoteMcpConnector`, `CallbackMcpConnector`, `LocalModelMcpConnector`, `DataStoreMcpConnector`, `SkillStoreMcpConnector` (in `src/connectors/`) | **[RestConnector/](./RestConnector/)** | **[McpConnectorTemplate/](./McpConnectorTemplate/)** |
13
13
  | `AgentConnector` | `NoOpAgentConnector` (in `src/connectors/`) | **[HttpWebhookAgentConnector/](./HttpWebhookAgentConnector/)** | — |
14
14
 
15
15
  **Bundled defaults** are runnable out of the box — wired through `connectors.json` substrate config or programmatic bootstrap.
16
16
 
17
- **Worked examples** are real implementations for substrates that aren't bundled — copy + customize for your specific deployment. HttpWebhookAgentConnector demonstrates the AgentConnector contract against a generic HTTP-webhook substrate.
17
+ **Worked examples** are real implementations for substrates that aren't bundled — copy + customize for your specific deployment. HttpWebhookAgentConnector demonstrates the AgentConnector contract against a generic HTTP-webhook substrate. RestConnector demonstrates that the McpConnector contract is wire-protocol-agnostic — it fronts a plain REST/HTTP API (no MCP wire protocol), and coexists in the same registry with actual MCP connectors.
18
18
 
19
19
  **Fork templates** are skeletons (every method throws TODO). Useful when you want the bare contract surface without any specific substrate assumptions. SkillStoreTemplate is the starting point for Postgres-, MongoDB-, AMP-, or vector-DB-backed SkillStore impls.
20
20
 
@@ -0,0 +1,19 @@
1
+ # RestConnector — example .env config.
2
+ # Copy to .env in your project root + fill in real values. Never commit the token.
3
+ #
4
+ # This connector reads its token from the env var named by `authTokenEnvVar`
5
+ # in your wiring. The example wires:
6
+ # new RestConnector({ baseUrl: "...", authTokenEnvVar: "TICKETS_API_TOKEN" })
7
+ # so it looks up process.env.TICKETS_API_TOKEN at call time.
8
+ #
9
+ # Rename the var to match your service; keep the wiring and the var name in sync.
10
+ TICKETS_API_TOKEN=
11
+
12
+ # The connector's `baseUrl` is passed in code / connectors.json, not here — but
13
+ # if you templatize connectors.json with env expansion, you might also keep:
14
+ # TICKETS_API_BASE_URL=https://api.internal.acme.io/v1
15
+
16
+ # Credential-free egress alternative: if you deploy behind an outbound proxy
17
+ # that injects auth, leave TICKETS_API_TOKEN empty, drop authTokenEnvVar from
18
+ # the wiring, and point baseUrl at the gateway. No secret lives in the runtime.
19
+ # See docs/adopter-playbook.md (proxy-egress pattern).
@@ -0,0 +1,109 @@
1
+ # RestConnector — a working REST-backed connector
2
+
3
+ A **complete, runnable** `McpConnector` that fronts a plain REST/HTTP API. Unlike [`McpConnectorTemplate`](../McpConnectorTemplate/) (a throws-`TODO` skeleton), you can register this as-is and dispatch to a real backend — you only edit the endpoint table and auth.
4
+
5
+ ## Why this exists
6
+
7
+ It answers one question adopters keep asking: **"my backend speaks REST, not the MCP wire protocol — can skillscript still call it?"**
8
+
9
+ Yes. `McpConnector` is the *dispatch surface skills call* (`$ connector.tool`), **not** a requirement that the backend speak MCP. The name is about the skill-facing verb, not the wire:
10
+
11
+ | Connector | Backend wire protocol | Same contract? |
12
+ |---|---|---|
13
+ | `HttpMcpConnector` (bundled) | JSON-RPC-over-HTTP (it fronts MCP servers) | ✅ |
14
+ | **`RestConnector` (this)** | plain REST/HTTP | ✅ |
15
+ | a WebSocket / gRPC / in-process fork | anything | ✅ |
16
+
17
+ All satisfy the same two-method contract (`call` + `manifest`). **A skill can't tell them apart**, and one skill body can mix them freely:
18
+
19
+ ```
20
+ $ gmail.send to="ops@acme.io" subject="Deploy done" # an MCP connector
21
+ -> _
22
+ $ tickets.create title="Deploy 4.2" severity="info" # this REST connector
23
+ -> ticket
24
+ ```
25
+
26
+ The registry holds a heterogeneous set; each `$ <name>.<tool>` op routes to whichever connector owns that name.
27
+
28
+ ## What you edit
29
+
30
+ Three things, all in [`RestConnector.ts`](./RestConnector.ts):
31
+
32
+ 1. **`ENDPOINTS`** — one entry per tool: HTTP method + path (with `:param` placeholders) + description + optional `inputSchema`. This is the tool surface skills dispatch to.
33
+ 2. **`RestConnectorConfig`** (via the constructor or `connectors.json`) — `baseUrl`, auth header, token source.
34
+ 3. **Registration** — programmatic or declarative (below).
35
+
36
+ Everything else works as written: path templating (`:id` → path, filled from args), query-vs-body routing (GET/DELETE → query string, POST/PUT/PATCH → JSON body), auth header injection, per-request timeout, error surfacing via `throw` (so op-level `(fallback: ...)` catches it), `staticTools()` lint, and `describeTools()` discovery.
37
+
38
+ ## Wiring
39
+
40
+ **Programmatic** (your bootstrap):
41
+
42
+ ```typescript
43
+ import { Registry } from "skillscript-runtime";
44
+ import { RestConnector } from "./RestConnector.js";
45
+
46
+ const registry = new Registry();
47
+ registry.registerMcpConnector(
48
+ "tickets",
49
+ new RestConnector({
50
+ baseUrl: "https://api.internal.acme.io/v1",
51
+ authTokenEnvVar: "TICKETS_API_TOKEN", // read from env, never hardcode
52
+ }),
53
+ );
54
+ ```
55
+
56
+ **Declarative** (`connectors.json`) — register the class once, then adopters declare instances in JSON:
57
+
58
+ ```typescript
59
+ import { registerConnectorClass } from "skillscript-runtime/connectors";
60
+ import { RestConnector } from "./RestConnector.js";
61
+
62
+ registerConnectorClass("RestConnector", {
63
+ ctor: RestConnector,
64
+ fromConfig: (cfg) => RestConnector.fromConfig(cfg),
65
+ });
66
+ ```
67
+
68
+ ```json
69
+ {
70
+ "tickets": {
71
+ "class": "RestConnector",
72
+ "config": { "baseUrl": "https://api.internal.acme.io/v1", "authTokenEnvVar": "TICKETS_API_TOKEN" }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Call `registerConnectorClass` **before** `loadConnectorsConfig` runs. This makes the *instance* JSON-configurable; the `ENDPOINTS` table is still code — a fully config-only REST connector (endpoints in JSON too) is a natural next fork.
78
+
79
+ ## Credentials
80
+
81
+ - **Prefer `authTokenEnvVar`** over a literal `authToken` — never commit a token. See [`.env.example`](./.env.example).
82
+ - Default header is `Authorization: Bearer <token>`. For an API-key header, set `authHeader: "X-API-Key"` + `authScheme: "raw"`.
83
+ - **Credential-free egress:** if you deploy behind an outbound proxy that injects auth (so no token lives in the runtime at all), you don't need `authToken`/`authTokenEnvVar` here — point `baseUrl` at the gateway. See the proxy-egress pattern in [`docs/adopter-playbook.md`](../../../docs/adopter-playbook.md).
84
+
85
+ ## What the contract does *not* have
86
+
87
+ There is **no `mutating` flag** on the tool descriptor (`McpToolDescriptor` is `{ name, description?, inputSchema? }`). The read/write distinction rides in the description text + the HTTP method — see how `describeTools()` prefixes `[POST]` / `[GET]`. If your host needs a first-class effect classification, that's a skill-body concern (the op's declared footprint), not a connector-descriptor field.
88
+
89
+ ## Identity propagation
90
+
91
+ `call()` receives `ctxOverrides` (`agentId`, `isAdmin`) but this example ignores it (`supports_identity_propagation: false`). To honor it, forward it as a header (`headers["X-On-Behalf-Of"] = ctx.agentId`) and flip the flag — which then obligates the Level 1 / Level 2 conformance probes documented in [`McpConnectorTemplate`](../McpConnectorTemplate/McpConnectorTemplate.ts).
92
+
93
+ ## Contract surface
94
+
95
+ | Method | Required | What it does |
96
+ |---|---|---|
97
+ | `call(toolName, args, ctx?)` | ✅ | Map tool + args → an HTTPS request; return parsed JSON |
98
+ | `manifest()` | ✅ | Transport metadata for `runtime_capabilities` discovery |
99
+ | `describeTools()` | optional | Endpoints-as-tools for author-time discovery + input lint |
100
+ | `staticCapabilities()` | ✅ static | Declare supported features |
101
+ | `staticTools()` | optional static | Closed tool set → lint validates `$ name.tool` at authoring time |
102
+
103
+ ## Further reading
104
+
105
+ - **[`../McpConnectorTemplate/`](../McpConnectorTemplate/)** — the fork-me skeleton + the full contract walkthrough (identity propagation, `fromConfig`, capability flags)
106
+ - **[`../../../docs/connector-contract-reference.md`](../../../docs/connector-contract-reference.md)** — the connector contracts, and why they're wire-protocol-agnostic
107
+ - **[`../../../docs/adopter-playbook.md`](../../../docs/adopter-playbook.md)** — wiring patterns + the credential-free proxy-egress deployment
108
+ - **`src/connectors/types.ts`** — authoritative `McpConnector` interface
109
+ - **`src/connectors/http-mcp.ts`** — `HttpMcpConnector`, the bundled HTTP impl (JSON-RPC-over-HTTP, for actual MCP servers)