skillscript-runtime 0.17.0 → 0.17.1
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/docs/adopter-playbook.md
CHANGED
|
@@ -230,6 +230,109 @@ SKILLSCRIPT_HOME=/path/to/adopter skillfile dashboard --config /path/to/adopter/
|
|
|
230
230
|
|
|
231
231
|
Each instance reads its own config; ports/paths/db files don't collide.
|
|
232
232
|
|
|
233
|
+
## Authoring posture — who owns the skills you write
|
|
234
|
+
|
|
235
|
+
Every skill stored in a `SkillStore` carries a `SkillMeta.author` field captured at first-write. The author is then load-bearing at dispatch time: the runtime threads it into `ctx.agentId` so identity-scoped substrates (memory stores, multi-tenant DBs) read and write under that scope.
|
|
236
|
+
|
|
237
|
+
How `author` is captured depends on how the skill gets written:
|
|
238
|
+
|
|
239
|
+
- **CLI / dashboard / direct programmatic API.** When you call `SkillStore.store(name, body)` from your own code (CLI, bootstrap, scripts) or via the dashboard's approval flow, the SkillStore captures author from its bundled default. `FilesystemSkillStore` uses `os.userInfo().username`; adopter stores capture from their own auth context.
|
|
240
|
+
|
|
241
|
+
- **MCP `skill_write` from a single-tenant host.** If only one agent (or one human) calls your runtime, you don't need to configure anything — the `SkillStore.store()` default-author logic above applies. **Skip the multi-agent section below.**
|
|
242
|
+
|
|
243
|
+
- **MCP `skill_write` from a multi-agent host.** If multiple agents share one runtime instance via MCP (e.g., a host that bridges several authenticated agents into one transport), the runtime can't tell them apart at the protocol layer. See the next section.
|
|
244
|
+
|
|
245
|
+
### Direct-write authoring path
|
|
246
|
+
|
|
247
|
+
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).
|
|
248
|
+
|
|
249
|
+
**Gotcha:** direct-write must declare `# Status: Draft`, not `# Status: Approved`. The runtime's hash-token tamper gate (v0.9.0) rejects skills with `# Status: Approved` that lack a `vN:<token>` stamp; the stamp is computed by the runtime's `update_status` flow, not by the substrate. To publish:
|
|
250
|
+
|
|
251
|
+
1. Write the skill with `# Status: Draft` via your substrate's direct-write API.
|
|
252
|
+
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.
|
|
253
|
+
|
|
254
|
+
Write-Approved-without-stamp will fail at execute time with `ApprovalRejectedError`. Always Draft-then-promote.
|
|
255
|
+
|
|
256
|
+
## Identity propagation — for multi-agent hosts
|
|
257
|
+
|
|
258
|
+
**Skip this section** if your runtime serves one agent (CLI tools, single-user dashboards, hobby deployments). The existing v0.16.8 default — `SkillMeta.author` captured from the SkillStore's writer identity — already attributes authorship correctly when there's only one writer.
|
|
259
|
+
|
|
260
|
+
This section is for adopters whose runtime is fronted by an MCP host that bridges multiple authenticated agents into one transport (e.g., a NanoClaw-style multi-agent gateway, or a multi-tenant SaaS where agents share a runtime pool).
|
|
261
|
+
|
|
262
|
+
### The gap MCP doesn't close on its own
|
|
263
|
+
|
|
264
|
+
JSON-RPC over HTTP doesn't carry a standard "calling identity" field. Without an extra convention, every `skill_write` call into your runtime stamps `SkillMeta.author = <runtime's own writer identity>` — regardless of which agent on the host actually originated the call. Subsequent `execute_skill` dispatches then run under the wrong scope. Identity-scoped reads return the runtime's own data, not the calling agent's.
|
|
265
|
+
|
|
266
|
+
### Opt-in: a configurable inbound header
|
|
267
|
+
|
|
268
|
+
When you configure `dashboard.mcpCallerIdentityHeader`, the runtime reads that header on every `/rpc` request and threads its value as the caller-identity through to `skill_write`. The handler stamps `SkillMeta.author = <header value>`. Different callers with different header values get distinct stored authors.
|
|
269
|
+
|
|
270
|
+
```json
|
|
271
|
+
{
|
|
272
|
+
"dashboard": {
|
|
273
|
+
"host": "127.0.0.1",
|
|
274
|
+
"port": 7878,
|
|
275
|
+
"mcpCallerIdentityHeader": "X-Agent-Id"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Multi-agent host (NanoClaw, custom MCP gateway, etc.) is responsible for setting the header on every outbound request:
|
|
281
|
+
|
|
282
|
+
```http
|
|
283
|
+
POST /rpc HTTP/1.1
|
|
284
|
+
Host: skillscript-runtime
|
|
285
|
+
Content-Type: application/json
|
|
286
|
+
X-Agent-Id: alice
|
|
287
|
+
|
|
288
|
+
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"skill_write","arguments":{"name":"alice-skill","source":"..."}}}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- Header lookup is case-insensitive (Node lowercases inbound header names).
|
|
292
|
+
- Absent header on a configured runtime → caller identity is undefined for that request → `SkillStore.store()` falls back to its default author capture (existing single-tenant behavior). Backwards-compatible — hosts that don't inject identity behave exactly as before.
|
|
293
|
+
- Empty header value → treated as absent.
|
|
294
|
+
|
|
295
|
+
### Trust model
|
|
296
|
+
|
|
297
|
+
The runtime trusts the host's header attestation. There's no signature verification — anyone reaching the runtime with a forged `X-Agent-Id` could claim to be anyone. The runtime is **not** the authentication boundary; the host is. Bilateral trust:
|
|
298
|
+
|
|
299
|
+
- **The host** (your MCP gateway) authenticates the agent via its own auth surface (OAuth, JWT, session cookies, mTLS — whatever fits your platform) and injects the verified identity into the outbound `X-Agent-Id` header.
|
|
300
|
+
- **The runtime** trusts the host because you configured it to (`mcpCallerIdentityHeader` is opt-in; unset means "I don't trust any inbound identity claim, fall back to my own writer identity").
|
|
301
|
+
|
|
302
|
+
Don't expose the `/rpc` endpoint directly to untrusted clients with this configuration. Run behind your host's auth-enforcing reverse proxy or in a trusted-network deployment.
|
|
303
|
+
|
|
304
|
+
### Inbound vs outbound — same header, two layers
|
|
305
|
+
|
|
306
|
+
Connectors like `HttpMcpConnector` use the **same header name** (`X-Agent-Id` by convention) for outbound calls to substrates — see the [HttpMcpConnector configuration](#case-2--mcp-tools-wiring-substrate-locked) above. The two are NOT the same value in general:
|
|
307
|
+
|
|
308
|
+
- **Inbound** (this section) = request-scoped caller — who's currently calling the runtime via MCP.
|
|
309
|
+
- **Outbound** (`HttpMcpConnector.identityHeader`) = dispatch-scoped owner — derived from `SkillMeta.author` of the skill being executed, asserted to the substrate so reads land in the owner's scope.
|
|
310
|
+
|
|
311
|
+
They MEET at `SkillMeta.author`. The runtime captures inbound caller identity at `skill_write` (stamps it as the skill's author); at execute time, the runtime threads `author` into `ctx.agentId`; the outbound connector asserts that to the substrate. The same `X-Agent-Id` header carries two different identity claims at the two boundaries; the stored author is the bridge.
|
|
312
|
+
|
|
313
|
+
**Critical:** never forward an inbound `X-Agent-Id` header straight to an outbound connector. The skill's owner is who should access the substrate, not the current caller. If anyone invokes alice's skill and the outbound used the caller's identity instead of alice's, the substrate would scope to the caller — a setuid hazard. The runtime keeps the two separate; outbound identity is always derived from author at dispatch.
|
|
314
|
+
|
|
315
|
+
### Verification
|
|
316
|
+
|
|
317
|
+
After wiring + restart, a smoke test:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
# Write a skill as alice
|
|
321
|
+
curl -X POST http://localhost:7878/rpc \
|
|
322
|
+
-H "content-type: application/json" \
|
|
323
|
+
-H "X-Agent-Id: alice" \
|
|
324
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"skill_write","arguments":{"name":"smoke","source":"# Skill: smoke\n# Status: Draft\nrun:\n emit(text=\"hi\")\ndefault: run"}}}'
|
|
325
|
+
|
|
326
|
+
# Verify author was stamped from the header
|
|
327
|
+
curl -X POST http://localhost:7878/rpc \
|
|
328
|
+
-H "content-type: application/json" \
|
|
329
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"skill_metadata","arguments":{"name":"smoke"}}}' \
|
|
330
|
+
| jq '.result.content[0].text | fromjson | .metadata.author'
|
|
331
|
+
# Expected: "alice"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
If the second call returns the runtime's own writer identity instead of `"alice"`, either the config field is unset, the header didn't reach the runtime (check your proxy / host wiring), or you sent the request with a different header name than configured.
|
|
335
|
+
|
|
233
336
|
## Conventions for upstream-merge-friendly modifications
|
|
234
337
|
|
|
235
338
|
If your wiring needs require modifying skillscript-runtime source (rather than just configuration), follow these conventions to minimize merge friction.
|
|
@@ -14,13 +14,9 @@ These are features designed or anticipated but not yet implemented in the curren
|
|
|
14
14
|
- **`while CONDITION:` loops** — today's iteration is `foreach IDENT in EXPR:` only. While loops are planned for ad-hoc orchestration patterns ("loop until response contains 'done'").
|
|
15
15
|
- **Arithmetic in `$set`** — today accepts literals + `${VAR}` interpolation; no `+ - * /` operators. Planned alongside `while` for turn counters and orchestration bookkeeping.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Pipe filter extensions
|
|
18
18
|
|
|
19
|
-
`|max`, `|min`, `|sum`, `|reduce`
|
|
20
|
-
|
|
21
|
-
## Strings
|
|
22
|
-
|
|
23
|
-
- **Multi-line / heredoc string literals** — today's `emit(text="...")` accepts single-line strings or `\n`-escaped multi-line. Planned: Python-style triple-quote `emit(text="""...""")` for ad-hoc prose blocks in template-kind skills.
|
|
19
|
+
- **Array aggregation primitives** — `|max`, `|min`, `|sum`, `|reduce` over arrays. Today the language tops out at "shape one record" — aggregating across an array requires `foreach` accumulator ceremony. Planned as a design question: is `foreach` the deliberate ceiling for aggregation, or do we add primitives?
|
|
24
20
|
|
|
25
21
|
## Triggers (parse-clean today, don't fire — no event-bus surface yet)
|
|
26
22
|
|
|
@@ -86,7 +82,6 @@ Most "right time" reasoning is relative, not wall-clock.
|
|
|
86
82
|
- **Channel/locality awareness** — `$(CHANNEL_TYPE)`, `$(CHANNEL_PRIVACY)` ambient refs for routing decisions
|
|
87
83
|
- **Introspection primitives** — `$(PROMPT_CONTEXT.size)`, `$(SKILLS_FIRED_RECENTLY.last-1h)`, `$(SELF.confidence-trend)`
|
|
88
84
|
- **Capability declarations** — `# Requires-Capabilities: sensors=[mic, camera], tools=[...]` (audit surface for operators)
|
|
89
|
-
- **`unknown-llm-arg` lint** — typo-catch for `$ llm` kwargs (today, unknown kwargs pass through silently to the connector). Sibling to the older `unknown-retrieval-arg` rule. Sharpening pass on `$ llm` typed-contract validation.
|
|
90
85
|
|
|
91
86
|
## When the language extends, this section shrinks
|
|
92
87
|
|
|
@@ -255,7 +250,7 @@ Default `.gitignore` for a file-backed skills repo: `*.skill` and `*.skill.prove
|
|
|
255
250
|
|
|
256
251
|
## Authoring discipline
|
|
257
252
|
|
|
258
|
-
|
|
253
|
+
Three principles for skill authors, learned by accumulated failure across many agent-authored skills.
|
|
259
254
|
|
|
260
255
|
### Don't encode deterministic implementation details
|
|
261
256
|
|
|
@@ -275,6 +270,33 @@ Write descriptions as *trigger conditions*: "if X happens, run this." Not as sum
|
|
|
275
270
|
|
|
276
271
|
This matters at scale. When a skill library grows past ~20 skills, the difference between "agents find the right skill" and "agents waste effort discovering the wrong one" is description-quality discipline.
|
|
277
272
|
|
|
273
|
+
### Comparison is orchestration; computation goes in tools
|
|
274
|
+
|
|
275
|
+
The conditional grammar (`==` / `<` / `>` / `in` / `and` / `or` / `not`) lives in the language because conditionals ARE orchestration decisions. Arithmetic, aggregation, transformation — these produce values, which is computation, which belongs in tools.
|
|
276
|
+
|
|
277
|
+
When you reach for a primitive that isn't in the language (`|max` over an array, modular arithmetic, regex substitution, date math), the stopping rule isn't "feature shelved." It's: *can a tool do this work, and can the skill body invoke that tool via `$` or `shell`?* Almost always yes.
|
|
278
|
+
|
|
279
|
+
**The universal computation escape is `shell + standard CLI tools.**
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
shell(command="echo ${RAW|shell} | jq -r '[.weather[0].hourly[] | .chanceofrain | tonumber] | max'", unsafe=true) -> MAX_RAIN
|
|
283
|
+
if ${MAX_RAIN} > "20":
|
|
284
|
+
emit(text="Rain chance today: ${MAX_RAIN}%")
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The skill body stays orchestration-shaped — fetch → compute → compare → emit. The aggregation (jq's `| max`) happens inside the shell call, not inside the skill grammar. `${RAW|shell}` neutralizes interpolation safely; `unsafe=true` is required for pipe characters.
|
|
288
|
+
|
|
289
|
+
Common shells of this pattern:
|
|
290
|
+
- Array aggregation → `jq '... | max/min/length/add'`
|
|
291
|
+
- Field extraction → `jq -r '.field'` or `awk '{print $N}'`
|
|
292
|
+
- String manipulation → `sed`, `cut`, `tr`
|
|
293
|
+
- Regex → `grep -E`, `sed 's///'`
|
|
294
|
+
- Date math → `date -d` or `python -c 'import datetime; ...'`
|
|
295
|
+
|
|
296
|
+
When a native primitive eventually ships (e.g., `|max` lands as a language filter), the shell-based version stays correct; the native one is a more skillscript-shaped spelling of the same work. The shell escape is never the wrong answer; it's the always-available answer.
|
|
297
|
+
|
|
298
|
+
**The stopping rule:** before declaring "feature blocked on missing primitive X," ask: *does shell + a standard tool already do this?* The design philosophy already provides the path; the question is whether a more native spelling would be cleaner.
|
|
299
|
+
|
|
278
300
|
## Ops reference — three op classes (mutation / runtime-intrinsic / external MCP dispatch)
|
|
279
301
|
|
|
280
302
|
The op surface is three classes, each with its own grammar and resolution path.
|
|
@@ -371,6 +393,24 @@ emit(text="${REPORT}")
|
|
|
371
393
|
|
|
372
394
|
Substitutions resolved at runtime. Ordering within a block: ops execute sequentially in source order.
|
|
373
395
|
|
|
396
|
+
**Multi-line strings via triple-quote:**
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
emit(text="""
|
|
400
|
+
Follow these directions exactly, step by step.
|
|
401
|
+
|
|
402
|
+
Pull the current state from ${SOURCE}.
|
|
403
|
+
Apply the transformation.
|
|
404
|
+
Verify the output matches ${EXPECTED_SHAPE}.
|
|
405
|
+
|
|
406
|
+
Report results to ${RECIPIENT}.
|
|
407
|
+
""")
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Triple-quote string literals (`"""..."""`) accept multi-line bodies. Whitespace-strip semantics: Python `textwrap.dedent` pattern — strip common leading whitespace across non-blank lines, strip leading + trailing blank lines. Dedent runs before `${VAR}` interpolation, so substituted values arrive verbatim into the dedented template and keep their own whitespace.
|
|
411
|
+
|
|
412
|
+
Triple-quote is a literal type that any kwarg accepts — not emit-specific. `$ llm prompt="""..."""`, `notify(message="""...""")`, and `$ data_write content="""..."""` all parse. Most natural fit is `emit(text=...)` in template-kind skills delivering prose, but the parser doesn't restrict use to that op.
|
|
413
|
+
|
|
374
414
|
Per-output-kind consumption semantics: presentation surfaces (`# Output: agent: <name>`, `# Output: template: <name>`) consume the joined emit stream as the delivered payload. Programmatic surfaces (`# Output: text`, `# Output: file:`) follow the per-kind semantics described in Output targets.
|
|
375
415
|
|
|
376
416
|
### `notify` — mid-skill agent alert
|
|
@@ -435,6 +475,8 @@ brief:
|
|
|
435
475
|
|
|
436
476
|
Inlines an Approved `# Type: data` skill into the host skill's compiled artifact at the call site. Resolved at `compile()` time; the data skill's `content_hash` is recorded in the host's provenance. `skillscript audit` detects stale recompiles when a referenced data skill changes.
|
|
437
477
|
|
|
478
|
+
**Identity semantic.** Inlined helpers run under the **host skill's identity** — compile-time paste makes the inlined ops part of the host body, so there is no runtime identity boundary to cross. Contrast with `execute_skill` where the called skill runs under its own author identity. Bringing code in via `inline` is an act of taking responsibility for it.
|
|
479
|
+
|
|
438
480
|
See Composition section for the distinction between `inline` (compile-time), `execute_skill` (in-skill runtime call), and dispatched skills.
|
|
439
481
|
|
|
440
482
|
### `execute_skill` — composition runtime call
|
|
@@ -446,6 +488,8 @@ classify:
|
|
|
446
488
|
|
|
447
489
|
Runtime-resolved against the SkillStore. Recursion-depth-guarded (default 10).
|
|
448
490
|
|
|
491
|
+
**Identity semantic.** Calls run under the **called skill's author identity** (`SkillMeta.author`), not the caller's — same rule as direct dispatch. When skill A (authored by Alice) invokes skill B (authored by Bob) via `execute_skill`, B runs as Bob. Each skill is its own identity unit; the dispatcher does not loan its identity to the callee. Cross-author delegation (caller-identity threaded as a separate context) is a future-ring design surface.
|
|
492
|
+
|
|
449
493
|
---
|
|
450
494
|
|
|
451
495
|
## External MCP dispatch
|
|
@@ -610,6 +654,8 @@ See the adopter playbook for the substrate config reference + the full Case 2 tr
|
|
|
610
654
|
|
|
611
655
|
**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.
|
|
612
656
|
|
|
657
|
+
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.
|
|
658
|
+
|
|
613
659
|
**Unquoted-substitution lint** (`unquoted-substitution-in-kwarg-value`, tier-2): fires when `$ x.y key=${VAR}` has unquoted `${VAR}` AND the var's binding origin is "suspect" (`# Vars:` default with whitespace, `$set` with whitespace, op output, foreach iterator). Closes the silent-arg-truncation footgun where the MCP arg parser whitespace-splits substituted values. Remediation: wrap as `key="${VAR}"`.
|
|
614
660
|
|
|
615
661
|
---
|
|
@@ -663,7 +709,7 @@ deliver:
|
|
|
663
709
|
|---|---|---|---|
|
|
664
710
|
| Mutation | `$set` | `$set NAME = value` (with `${VAR}` interpolation at bind) | NAME (no arrow) |
|
|
665
711
|
| Mutation | `$append` | `$append VAR <value>` (type-dispatched: list element / string concat) | VAR (no arrow) |
|
|
666
|
-
| Runtime-intrinsic | `emit` | `emit(text="...")` | none |
|
|
712
|
+
| Runtime-intrinsic | `emit` | `emit(text="...")` or `emit(text="""...""")` for multi-line | none |
|
|
667
713
|
| Runtime-intrinsic | `notify` | `notify(agent="...", [message=...], [event_type=...], [correlation_id=...]) -> ACK` | optional |
|
|
668
714
|
| Runtime-intrinsic | `inline` | `inline(skill="<name>")` | none (compile-time) |
|
|
669
715
|
| Runtime-intrinsic | `execute_skill` | `execute_skill(skill_name="...", inputs={...}) -> R` | optional |
|
|
@@ -811,6 +857,7 @@ Pipe filters apply transforms to resolved variables before substitution. Syntax:
|
|
|
811
857
|
| `json` | `JSON.stringify(value)` | `${payload|json}` for `{k:"v"}` | `"{\"k\":\"v\"}"` |
|
|
812
858
|
| `trim` | Whitespace trim | `${VERDICT|trim}` for `"urgent\n"` | `urgent` |
|
|
813
859
|
| `length` | Count of items (array) or characters (string) | `${ITEMS|length}` for `["a","b","c"]` | `3` |
|
|
860
|
+
| `contains:"X"` | Boolean: type-aware substring / element membership | `${MSG|contains:"urgent"}` for `"Yes, urgent"` | `true` |
|
|
814
861
|
| `fallback:"X"` | Coalesce on missing/undefined ref | `${VAR.missing|fallback:"-"}` | `-` |
|
|
815
862
|
| `isodate` | Epoch seconds → ISO-8601 timestamp | `${EPOCH|isodate}` for `1779660000` | `2026-05-24T22:00:00.000Z` |
|
|
816
863
|
|
|
@@ -832,6 +879,35 @@ if ${ITEMS|length} > 5:
|
|
|
832
879
|
|
|
833
880
|
The output of `|length` is a string-form number ("3", "5", etc.) at substitution time, consistent with how other filters produce strings. Numeric comparison coerces back to number for the comparison; equality (`==`) does byte-for-byte string comparison.
|
|
834
881
|
|
|
882
|
+
### `contains:"X"` semantics
|
|
883
|
+
|
|
884
|
+
Type-aware boolean filter for use in conditionals. Resolution branches:
|
|
885
|
+
|
|
886
|
+
- **LHS resolves to a list** → element membership. `${LIST|contains:"a"}` returns `true` if `"a"` is in the list, `false` otherwise. No substring matching against list elements.
|
|
887
|
+
- **LHS resolves to a string that JSON-parses to a list** → use the parsed list (JSON-string tolerance, same pattern as `in`/`not in` RHS). Accommodates LLM-output-as-JSON-array patterns.
|
|
888
|
+
- **LHS resolves to a string (non-JSON-array)** → substring match. `${TEXT|contains:"urgent"}` returns `true` if `"urgent"` appears anywhere in `TEXT`.
|
|
889
|
+
- **LHS resolves to anything else (object, number, null)** → stringify-then-substring. Last-resort behavior; not the recommended path.
|
|
890
|
+
|
|
891
|
+
Mirrors the existing `in` / `not in` operator semantics — `${LIST|contains:"a"}` and `if "a" in ${LIST}:` return the same answer for the same inputs. The two ways to ask "does this contain a value" stay symmetric.
|
|
892
|
+
|
|
893
|
+
```
|
|
894
|
+
$ llm prompt="Reply 'urgent' if any items are urgent" -> VERDICT
|
|
895
|
+
if ${VERDICT|contains:"urgent"}:
|
|
896
|
+
emit(text="escalate")
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Replaces the brittle exact-match pattern (`if ${VERDICT|trim} == "urgent":`) that required prompt-engineering the LocalModel to respond with EXACTLY one word.
|
|
900
|
+
|
|
901
|
+
Return convention: `"true"` on match, `""` (empty string) on miss. Matches the runtime's `isTruthy` so the natural shape `if ${R|contains:"X"}:` evaluates the way the syntax suggests — no explicit `== "true"` needed.
|
|
902
|
+
|
|
903
|
+
Case-sensitive by default. Case-insensitive variant deferred until empirical signal.
|
|
904
|
+
|
|
905
|
+
Quoted-string argument required. Bare-identifier accepted for consistency with other filter args but tier-2 lint warns "prefer quoted form."
|
|
906
|
+
|
|
907
|
+
Empty-string match: `${VAR|contains:""}` returns `true` if VAR is bound. Documented behavior — any string contains the empty string.
|
|
908
|
+
|
|
909
|
+
`contains` is the first filter to operate on structured types. The design line — filters are predominantly string ops — is intentionally crossed here because filter-as-conditional-primitive is the load-bearing intent. The rest of the filter set (`|trim`, `|json`, `|fallback`, etc.) operates on the resolved string form.
|
|
910
|
+
|
|
835
911
|
### `fallback:"X"` semantics
|
|
836
912
|
|
|
837
913
|
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.
|
|
@@ -872,11 +948,13 @@ First trims whitespace, then JSON-stringifies the result.
|
|
|
872
948
|
|
|
873
949
|
## Filter use in conditionals
|
|
874
950
|
|
|
875
|
-
Filters may appear on the LHS of conditional expressions. Useful for whitespace-tolerant equality checks against LocalModel output (which often has trailing newlines)
|
|
951
|
+
Filters may appear on the LHS of conditional expressions. Useful for whitespace-tolerant equality checks against LocalModel output (which often has trailing newlines), and for the `contains:` predicate-shaped filter:
|
|
876
952
|
|
|
877
953
|
```
|
|
878
954
|
if ${VERDICT|trim} == "urgent":
|
|
879
955
|
...
|
|
956
|
+
if ${VERDICT|contains:"urgent"}:
|
|
957
|
+
...
|
|
880
958
|
if ${VAR.maybe|fallback:"-"} == "-":
|
|
881
959
|
emit(text="nothing there")
|
|
882
960
|
```
|
|
@@ -930,9 +1008,9 @@ Several filters are planned but not yet shipped:
|
|
|
930
1008
|
|
|
931
1009
|
## Composition philosophy
|
|
932
1010
|
|
|
933
|
-
Filters are pure functions (input → output, no side effects). Stay small and orthogonal — each filter does one thing. Composition emerges from chaining, not from elaborate per-filter parameter spaces. The shipped set covers ~
|
|
1011
|
+
Filters are pure functions (input → output, no side effects). Stay small and orthogonal — each filter does one thing. Composition emerges from chaining, not from elaborate per-filter parameter spaces. The shipped set covers ~90% of real-world string-shaping needs; the pending set extends to slicing and array projection.
|
|
934
1012
|
|
|
935
|
-
`length`, `fallback:`, and `
|
|
1013
|
+
`length`, `fallback:`, `isodate`, and `contains:` were all added in response to cold-author harness signal — authored skills demonstrated the gap was load-bearing before each filter shipped. `contains:` is also notable as the first filter to operate on structured types; filter-as-conditional-primitive is the design line that warranted the cross.
|
|
936
1014
|
|
|
937
1015
|
## Conditionals & iteration — if/elif/else, foreach, supported operators
|
|
938
1016
|
|
|
@@ -2042,5 +2120,5 @@ Hung dispatches hang the skill without explicit timeout configuration. Lean: ski
|
|
|
2042
2120
|
|
|
2043
2121
|
---
|
|
2044
2122
|
|
|
2045
|
-
*Rendered from `skillscript/skillscript-language-reference` — 2026-06-
|
|
2123
|
+
*Rendered from `skillscript/skillscript-language-reference` — 2026-06-03 18:08 EDT*
|
|
2046
2124
|
*Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillscript-runtime",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Runtime, compiler, lint, CLI, and dashboard for Skillscript — a small declarative language for authoring agent workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Scott Shwarts <scotts@pobox.com>",
|