skillscript-runtime 0.13.2 → 0.13.5

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.
@@ -0,0 +1,1972 @@
1
+ # Skillscript Language Reference — syntax, ops, semantics
2
+
3
+ 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.
4
+
5
+ Implementation state is cross-referenced to commit hashes; pending items mark v2/v3 work.
6
+
7
+ Companion docs under the Skillscript project anchor:
8
+ - `skillscript-prd` — product positioning, value prop, roadmap
9
+ - `skillscript-erd` — engineering requirements, system architecture, runtime mechanics
10
+
11
+ ## Not yet implemented, but planned
12
+
13
+ These are features designed or anticipated but not yet implemented in the current build. Authors should not use these forms; they will not compile.
14
+
15
+ ## Control flow
16
+
17
+ - **`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'").
18
+ - **Arithmetic in `$set`** — today accepts literals + `${VAR}` interpolation; no `+ - * /` operators. Planned alongside `while` for turn counters and orchestration bookkeeping.
19
+
20
+ ## Strings
21
+
22
+ - **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.
23
+
24
+ ## Triggers (parse-clean today, don't fire — no event-bus surface yet)
25
+
26
+ - `event:` — generic event-bus subscription
27
+ - `agent-event:` — agent-emitted events
28
+ - `file-watch:` — filesystem change events
29
+ - `sensor:` — continuous sensor channels
30
+
31
+ ## Synchronous agent exchange
32
+
33
+ - **`exchange()` runtime-intrinsic op** — synchronous send + wait pattern for multi-agent conferences. Awaits adopter-substrate queue impl + AgentConnector contract grow.
34
+
35
+ ## Tests
36
+
37
+ - **`# Tests:` block** with `given:` / `expect:` assertions — author-authored test cases. Will land when adopter signal demands test infrastructure.
38
+
39
+ ## Output kinds
40
+
41
+ - **`# Output: file: <path>`** — file-output routing parses but no router exists today.
42
+ - **`# Output: card:`** — depends on a substrate-side card render surface; not implemented.
43
+
44
+ ## Persistent state with declared scope
45
+
46
+ ```
47
+ $set NAME = value scope=skill-local
48
+ $set NAME = value scope=agent-global
49
+ $set NAME = value scope=session
50
+ ```
51
+
52
+ Scopes: skill-local (persists across fires of this skill, not visible to other skills), agent-global (visible to all skills of the same agent), session (alive for the duration of the current session, cleared at session end). Backed by a configured data-records connector.
53
+
54
+ ## Per-skill / per-op timeouts
55
+
56
+ `# Timeout:` skill-level header + per-op `timeoutSeconds=N` kwarg + runtime defaults. Hung dispatches today have no timeout cap.
57
+
58
+ ## Sensors as a language category
59
+
60
+ Distinct from triggers. Sensors are continuous channels the agent reads but doesn't emit on. Planned syntax:
61
+
62
+ ```
63
+ # Sensors: presence, screen-state, voice-prosody
64
+ ```
65
+
66
+ Ambient refs `$(SENSOR.presence)`, `$(SENSOR.voice-prosody.affect)` for read access. Privacy-gating discipline determines when a sensor is readable.
67
+
68
+ ## Time as first-class primitives
69
+
70
+ Currently `$(NOW)` (wall-clock). Planned relative-time primitives:
71
+
72
+ ```
73
+ $(SECONDS_SINCE_LAST_USER_MESSAGE)
74
+ $(MINUTES_SINCE_SESSION_START)
75
+ $(SECONDS_SINCE_LAST_FIRE_OF.<skill-name>)
76
+ ```
77
+
78
+ Most "right time" reasoning is relative, not wall-clock.
79
+
80
+ ## Other planned
81
+
82
+ - **Absence-as-trigger** — `# Triggers: idle: 5m` fire-on-quiet primitive
83
+ - **Time-windowed aggregation** — filter-like primitives across firings (e.g., "user has shown frustration in 3 of 5 recent turns")
84
+ - **Debounce / rate-limit / coalesce** — declarative queueing policy headers
85
+ - **Suppression as valid output** — explicit "fire-and-suppress" (different from `# Output: none`)
86
+ - **Cross-skill pub-sub** — `# Publishes: signal.X` / `# Subscribes: signal.Y` decoupling
87
+ - **Confidence/threshold gating** — `# RequiresConfidence: classifier >= 0.8` / `# RequiresThreshold:`
88
+ - **Invocation-control axis** — `# Invocable-By: user | agent | trigger` (sensitive ops shouldn't leak across invocation boundaries)
89
+ - **Channel/locality awareness** — `$(CHANNEL_TYPE)`, `$(CHANNEL_PRIVACY)` ambient refs for routing decisions
90
+ - **Introspection primitives** — `$(PROMPT_CONTEXT.size)`, `$(SKILLS_FIRED_RECENTLY.last-1h)`, `$(SELF.confidence-trend)`
91
+ - **Capability declarations** — `# Requires-Capabilities: sensors=[mic, camera], tools=[...]` (audit surface for operators)
92
+
93
+ ## When the language extends, this section shrinks
94
+
95
+ When any of these primitives ship, the relevant grammar moves into its canonical section (Ops reference, Variables, Triggers, etc.) and the entry here is removed. This section stays alive as a continuous staging area for the next horizon of unshipped work.
96
+
97
+ ## Overview & language model
98
+
99
+ Skillscript is a constrained domain-specific language for authoring agent workflows. A skillscript is a declarative recipe: a small program with a dependency DAG of named targets, each composed of typed operations. Skillscripts are written once and executed many times.
100
+
101
+ ## Language model — trigger → process → deliver
102
+
103
+ Every skill follows the same shape:
104
+
105
+ 1. **Trigger** — what fires the skill: cron, command, session-start, agent-event, file-watch, webhook, etc.
106
+ 2. **Process** — pull data (MCP / memory / file), classify or compose via sub-LLM + iteration, build the deliverable.
107
+ 3. **Deliver** — emit the result via one or more delivery channels (see below).
108
+
109
+ Skillscript's job is to express this pipeline declaratively. When there is an agent above the skill, the agent's job is to act on the delivered artifact. When there isn't (autonomous fires), the delivery channel IS the outcome.
110
+
111
+ **Declarative DAG, not imperative script.** A skillscript declares targets and their dependencies (`needs:` keyword); the interpreter topologically sorts and executes them in dependency order. Write blocks in any order — the runtime walks the graph.
112
+
113
+ **Goal-directed, not entry-point-directed.** The `default:` declaration names the *goal target* — the terminal node whose result is the skill's output. The runtime walks dependencies backward from the goal through the topo-sort. A skill with a single target obscures this (goal == entry trivially); skills with multi-target DAGs make the shape visible.
114
+
115
+ ## Two execution paths
116
+
117
+ **Runtime-mediated** — the interpreter walks ops and dispatches them directly through configured connectors. Used for autonomous fires (cron, session-triggered, event-triggered). Safety boundary is the connector config + per-op gating (see Ops Reference).
118
+
119
+ **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.
120
+
121
+ The language is identical in both paths. The execution model is a deployment-time + invocation-time decision.
122
+
123
+ ## Three delivery channels
124
+
125
+ A skill delivers its work via one or more of three channels. Delivery channel is not a property of skill type — it's just which ops a skill ends with.
126
+
127
+ | Channel | Op | When you'd use it |
128
+ |---|---|---|
129
+ | **Augmenting (context to agent)** | `emit(text="...")` + `# Output: agent: <name>` | Skill output is augment-kind payload for the receiving agent's next turn; joined emit stream becomes the delivered context. Pattern: agent-augmenting skills (briefing skills, session-start prepared context). |
130
+ | **Template (playbook to agent)** | `emit(text="...")` + `# Output: template: <name>` | Skill output is a template-kind payload (recipe/playbook) the receiving agent executes. Pattern: instructional skills, reusable recipes. |
131
+ | **Memory handoff** | `$ memory_write content="..." recipients=[<agent>] -> R` | Skill writes a memory the target agent picks up via mailbox at next session. Pattern: async carrier skills, autonomous fires that hand off to a future session. |
132
+
133
+ A single skill can use any combination. An autonomous cron-fired sweep might write a memory to one agent AND emit augment-kind context to another. The combinations are unconstrained — the per-op gating model governs which mutating ops are authorized, not which channels a skill uses.
134
+
135
+ ## Three op classes
136
+
137
+ The op surface is three classes, each with its own grammar:
138
+
139
+ | Class | Shape | Resolution |
140
+ |---|---|---|
141
+ | **Mutation statements** | `$set VAR = value`, `$append VAR <value>` | Reserved keywords. |
142
+ | **Runtime-intrinsic function-calls** | `verb(kwarg=value, ...) [-> BINDING]` | Closed built-in list (see Ops Reference). Unknown verb → tier-1 lint `unknown-runtime-op`. |
143
+ | **External MCP dispatch** | `$ <connector> kwarg=value, ... [-> BINDING]` | Resolved against `connectors.json`. Unknown connector → tier-1 lint `unknown-connector`. |
144
+
145
+ The `$` prefix is information-bearing: it marks **state-affecting ops** (mutation OR external dispatch). Function-call shape marks **language-intrinsic ops the runtime knows directly**.
146
+
147
+ Full op catalog and per-op semantics in Ops Reference.
148
+
149
+ ## Substrate portability
150
+
151
+ The language doesn't privilege any backend. `$ llm`, `$ memory`, `$ ticketing_search` are not language built-ins — they're connector names resolved at runtime through the registered MCP connector instances. The same skill source runs against any conforming substrate.
152
+
153
+ The runtime ships bundled bridges for two common patterns:
154
+
155
+ - `$ llm` routes through whichever LocalModel is wired (via `substrate.local_model` in `connectors.json`)
156
+ - `$ memory` / `$ memory_write` route through whichever MemoryStore is wired (via `substrate.memory_store`)
157
+
158
+ Adopters wire OpenAI instead of Ollama, Pinecone instead of SQLite, etc. Configuration lives outside the skill body; the language remains agnostic.
159
+
160
+ | Connector slot | Adopter A wires | Adopter B wires |
161
+ |---|---|---|
162
+ | `llm` | LocalModel: Ollama (default) | LocalModel: OpenAI |
163
+ | `memory` / `memory_write` | MemoryStore: SqliteMemoryStore (default) | MemoryStore: Pinecone |
164
+ | `ticketing_search` | YouTrack MCP | Jira MCP |
165
+
166
+ Substrate config syntax + the three-form configuration shape lives in the adopter playbook, not in this reference. Skill authors don't typically need to touch it — they author against the canonical `$ tool` surfaces and adopters wire whatever's underneath.
167
+
168
+ ## Anatomy of a skill
169
+
170
+ A canonical example exercising trigger → process → deliver against the augmenting channel:
171
+
172
+ ```
173
+ # Skill: morning-showstopper-sweep
174
+ # Description: Pre-triage open showstoppers before the human arrives; deliver as augmenting context to the on-call agent.
175
+ # Triggers: cron:"0 8 * * MON-FRI"
176
+ # Output: agent: oncall
177
+ # Vars: PROJECT = "INFRA"
178
+
179
+ sweep:
180
+ # Process: pull showstoppers
181
+ $ ticketing_search query="project:${PROJECT} severity:showstopper state:Open" -> SHOWSTOPPERS
182
+
183
+ # Deliver header
184
+ emit(text="Morning showstoppers for ${PROJECT} (count: ${SHOWSTOPPERS.totalCount}):")
185
+ emit(text="")
186
+
187
+ # Process + deliver per issue (sub-LLM analysis bracketed by emit lines)
188
+ foreach ISSUE in ${SHOWSTOPPERS.items}:
189
+ $ llm prompt="Two-line summary + top hypothesis for: ${ISSUE.summary}" -> ANALYSIS
190
+ emit(text="## ${ISSUE.id}: ${ISSUE.summary}")
191
+ emit(text="${ANALYSIS}")
192
+ emit(text="")
193
+
194
+ default: sweep
195
+ ```
196
+
197
+ The joined `emit()` stream becomes the augment-kind payload delivered to the on-call agent (per `# Output: agent: oncall` declaration). The agent sees the briefing inline at next-turn dispatch.
198
+
199
+ **Three layers of declaration:**
200
+ 1. **Header metadata** (`# Key: value` lines) — name, description, declared variables, triggers, `# Output:` routing, optional `# Autonomous:` flag, error fallbacks
201
+ 2. **Targets** — named blocks of typed ops, optionally with `needs:` dependencies
202
+ 3. **`default:`** — names the goal target the runtime walks toward
203
+
204
+ Other delivery channels for the same shape: swap `# Output: agent: oncall` + the `emit()` calls for `$ memory_write content="..." recipients=["oncall"] approved="cron-fired" -> R` (one summary memory handoff per fire) or `file_write(path="/var/log/showstoppers-${EVENT.fired_at_unix}.md", content="...", approved="cron-fired")` (one file deliverable per fire).
205
+
206
+ ## Lexical conventions
207
+
208
+ ### Indentation: spaces only
209
+
210
+ Block structure (`foreach`, `if`/`elif`/`else:`, target bodies, error-handler `else:` blocks) is determined by indentation. **Use spaces. Tabs are a parse error.** Mixed tabs+spaces in a single file is a parse error.
211
+
212
+ The conventional indent is 4 spaces, but any consistent depth within a block is acceptable. The parser tracks each block's indent level on entry and rejects mid-block changes.
213
+
214
+ ### Reserved keywords
215
+
216
+ The following identifiers are reserved and cannot be used as variable names, target names, or skill names:
217
+
218
+ **Mutation statements:** `$set`, `$append`
219
+
220
+ **Runtime-intrinsic op names:** `emit`, `notify`, `ask`, `inline`, `execute_skill`, `shell`, `file_read`, `file_write` (the closed function-call list; see Ops Reference)
221
+
222
+ **Control flow:** `default`, `needs`, `if`, `elif`, `else`, `foreach`, `in`, `not`, `unsafe`
223
+
224
+ **Future-reserved** (no current semantics, reserved to keep future grammar additions non-breaking): `while`, `for`, `match`, `try`, `catch`, `return`. See the "Not yet implemented, but planned" section at the top for what's coming.
225
+
226
+ Reserved-name use produces a parse error with a specific diagnostic.
227
+
228
+ **Case sensitivity.** Reserved words are exact-match case-sensitive. `emit` is reserved; `Emit` is allowed. `If` is allowed as an identifier; `if` is the control-flow keyword.
229
+
230
+ ### Enumerated value normalization
231
+
232
+ For frontmatter keys with a closed set of accepted values (`# Status:`, `# Output:` kinds, trigger sources, etc.), values are accepted case-insensitively on input and stored as their canonical form. `# Status: draft`, `# Status: Draft`, and `# Status: DRAFT` all parse to the same canonical `Draft`.
233
+
234
+ This applies to value-space normalization only — keys remain case-sensitive (`# Status:` is the header; `# status:` is a parse error).
235
+
236
+ ## Storage and identity
237
+
238
+ Skillscripts are stored via a configured `SkillStore` backend. The backend persists each skill as a uniquely-named record; writing a skill with an existing name updates in place. Skill records are infrastructure, not knowledge atoms — backends with garbage-collection or expiry semantics should treat skills as long-lived first-class records, not as candidates for cleanup.
239
+
240
+ The language is storage-agnostic; the interpreter accepts a skillscript body as text regardless of source. The runtime ships three reference SkillStore implementations:
241
+
242
+ - **`FilesystemSkillStore`** — skill bodies on disk as `.skill.md` files. Common for filesystem-first authoring workflows (humans editing files in a Git repo).
243
+ - **`SqliteSkillStore`** — skill bodies in a SQLite database. Default for runtime hosts (MCP server, web dashboard) when adopters want substrate-native authoring (via dashboard or `skill_write` MCP).
244
+ - **Adopter-custom** — adopters write `class MySkillStore implements SkillStore` against the contract; runtime is none the wiser.
245
+
246
+ Substrate selection lives in `connectors.json` (adopter concern; details in the adopter playbook).
247
+
248
+ ### File-backed convention (FilesystemSkillStore)
249
+
250
+ Three-file pattern per skill on disk, mirroring the standard source/compiled split (`.ts`→`.js`, `.scss`→`.css`):
251
+
252
+ - `<skill-name>.skill.md` — **source.** Authored by humans or agents. Dual-extension: `.md` outer makes any markdown-aware tool render headers + code blocks natively; `.skill` inner is the language-tooling discriminator. Committed to version control.
253
+ - `<skill-name>.skill` — **compiled artifact.** The prompt text emitted by the compile API. Agent-consumable. Typically gitignored.
254
+ - `<skill-name>.skill.provenance.json` — **provenance sidecar.** Records source content_hash, compiled version, timestamps, data-skill staleness markers. Typically gitignored.
255
+
256
+ Default `.gitignore` for a file-backed skills repo: `*.skill` and `*.skill.provenance.json`.
257
+
258
+ ## Authoring discipline
259
+
260
+ Two principles for skill authors, learned by accumulated failure across many agent-authored skills.
261
+
262
+ ### Don't encode deterministic implementation details
263
+
264
+ Skills are orchestration; deterministic operations are tools. When tempted to hardcode a CLI version string, a REST endpoint payload structure, or an authentication handshake, the discipline says: *the work belongs in an MCP tool, not in the skill body*.
265
+
266
+ - **Drift.** CLI versions change. Endpoints change. The skill that hardcodes them breaks on next update; the MCP tool that abstracts them survives.
267
+ - **Substrate-portability.** A skill that knows "the API returns `{ user: {...} }`" is bound to one API shape. A skill that calls `$ user_fetch -> USER` and accesses `${USER.id}` works against any connector that conforms to the user-shape contract.
268
+ - **Authority.** Auth handshakes inside skill bodies leak credentials through skill source. Auth lives in the connector's identity-merge layer, not in the call site.
269
+
270
+ If the work feels deterministic and reproducible — a fixed parse, a fixed API call, a fixed shell pipeline — it's a tool. The skill body should invoke that tool via `$`, not re-implement it.
271
+
272
+ ### Describe when the skill should be invoked, not what it does
273
+
274
+ The `# Description:` header determines whether agents pick the right skill when multiple are available. A vague description ("Handles error responses") is roughly useless for invocation selection. A specific description ("Read `references/api-errors.md` if a downstream API returns non-200 status") fires the skill at exactly the right moment.
275
+
276
+ Write descriptions as *trigger conditions*: "if X happens, run this." Not as summaries. Authors who think of the description as the skill's elevator pitch produce skills that never get picked because the trigger condition isn't stated.
277
+
278
+ 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.
279
+
280
+ ## Ops reference — three op classes (mutation / runtime-intrinsic / external MCP dispatch)
281
+
282
+ The op surface is three classes, each with its own grammar and resolution path.
283
+
284
+ ## Three op classes at a glance
285
+
286
+ | Class | Shape | Resolution |
287
+ |---|---|---|
288
+ | **Mutation statements** | `$set VAR = value`, `$append VAR <value>` | Reserved keywords (parser dispatches directly). |
289
+ | **Runtime-intrinsic function-calls** | `verb(kwarg=value, ...) [-> BINDING]` | Closed built-in list (below). Unknown verb → tier-1 `unknown-runtime-op`. |
290
+ | **External MCP dispatch** | `$ <connector>[.<tool>] kwarg=value, ... [-> BINDING]` | `connectors.json` resolution at compile. Unknown connector → tier-1 `unknown-connector`. See External MCP dispatch subsection below for flat vs dotted dispatch shape. |
291
+
292
+ The `$` prefix is information-bearing: it marks **state-affecting ops** (mutation OR external dispatch). Function-call shape marks **language-intrinsic ops the runtime knows directly**. Parse-time discrimination is unambiguous — three grammars, three resolution paths, zero overlap.
293
+
294
+ All call-sites are uniform all-kwargs. No positional arguments. No mixed shapes. One call form per class.
295
+
296
+ ---
297
+
298
+ ## Mutation statements
299
+
300
+ ### `$set` — explicit variable binding
301
+
302
+ Binds a value to a variable. Bind-time interpolation of `${VAR}` substitutions in the RHS.
303
+
304
+ ```
305
+ $set RESULT = ""
306
+ $set MODE = "production"
307
+ $set GREETING = "Hello, ${NAME}!" # resolves at bind time
308
+ $set FOUND = []
309
+ ```
310
+
311
+ RHS forms accepted: string literal (with `${VAR}` substitution), number literal, boolean (`true` / `false`), `null`, empty list `[]`, JSON array literal, JSON object literal, or a single variable ref `${OTHER}`.
312
+
313
+ Missing-ref produces tier-1 runtime error.
314
+
315
+ ### `$append` — accumulator
316
+
317
+ Mutates the target binding in the outer scope. Type-dispatched on the target binding.
318
+
319
+ - **List target** → element append.
320
+ - **String target** → concatenation.
321
+ - **Number/object/null target** → tier-1 `append-to-non-list` lint error.
322
+
323
+ ```
324
+ # List-typed accumulator
325
+ walk:
326
+ $set SEEN = []
327
+ foreach C in ${CANDIDATES}:
328
+ if ${C.id} not in ${SEEN}:
329
+ $append SEEN ${C.id}
330
+ emit(text="NEW: ${C.id} — ${C.summary}")
331
+ emit(text="Total novel items: ${SEEN|length}")
332
+
333
+ # String-typed accumulator
334
+ build:
335
+ $set DETAIL = ""
336
+ $append DETAIL "Open issues for ${USER.login}:\n\n"
337
+ foreach ISSUE in ${ISSUES.items}:
338
+ $append DETAIL "- ${ISSUE.id}: ${ISSUE.summary}\n"
339
+ ```
340
+
341
+ **Initialization required.** `$append VAR <value>` where VAR isn't initialized in the enclosing scope (via `$set X = []`, `$set X = ""`, or `# Vars: X=[]` / `# Vars: X=""`) fires tier-1 `uninitialized-append`.
342
+
343
+ **Foreach scope rule.** When `$append VAR` is inside a `foreach`, VAR's init must live in an *enclosing* scope. Tier-1 `foreach-local-accumulator-target` catches this.
344
+
345
+ **Single-value semantics (list mode).** `$append VAR <value>` appends one element. List concatenation is deferred to a future `$extend` op.
346
+
347
+ **Parallel foreach.** `$append` inside a `parallel foreach` is a tier-1 error.
348
+
349
+ ---
350
+
351
+ ## Runtime-intrinsic function-calls
352
+
353
+ Closed list of language-intrinsic ops the runtime knows directly. Each is a function-call with kwargs; binding via optional `-> VAR`. The complete set:
354
+
355
+ | Op | Shape | Binding | Notes |
356
+ |---|---|---|---|
357
+ | `emit` | `emit(text="...")` | none | Append to the skill's emission stream; consumed by the configured `# Output:` delivery channel. |
358
+ | `notify` | `notify(agent="...", message="...", [event_type=...], [correlation_id=...]) -> ACK` | optional | Mid-skill agent alert; synchronous send via configured AgentConnector. |
359
+ | `ask` | `ask(prompt="...") -> R` | required | Prompt user for input; binds response. Autonomous-mode fails fast (routes to `else:` / `# OnError:`). |
360
+ | `inline` | `inline(skill="<data-skill-name>")` | none | Compile-time inline of an Approved `# Type: data` skill. Resolves at compile, records `content_hash` in provenance. |
361
+ | `execute_skill` | `execute_skill(skill_name="...", inputs={...}) -> R` | optional | Composition primitive. Runtime-resolved. See Composition section. |
362
+ | `shell` | `shell(command="...") -> R` / `shell(command="...", unsafe=true) -> R` | optional | Sandboxed shell exec (default) or full-shell exec (`unsafe=true`, gated by `runtime.enable_unsafe_shell`). stdout binds. |
363
+ | `file_read` | `file_read(path="...") -> R` | required | Read a file at `path`; binds string contents. |
364
+ | `file_write` | `file_write(path="...", content="...")` | none | Write `content` to `path`. `mkdir -p` semantics for parent directories. Mutation-classified. |
365
+
366
+ **Unknown op name** → tier-1 lint `unknown-runtime-op` with remediation pointing at MCP dispatch: "if this is an external tool, use `$ tool_name args -> R`."
367
+
368
+ ### `emit` — delivery-channel append
369
+
370
+ ```
371
+ emit(text="Triage for ${PROJECT}:")
372
+ emit(text="${REPORT}")
373
+ ```
374
+
375
+ Substitutions resolved at runtime. Ordering within a block: ops execute sequentially in source order.
376
+
377
+ 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.
378
+
379
+ ### `notify` — mid-skill agent alert
380
+
381
+ ```
382
+ notify(agent="oncall", message="Threshold breached at ${COUNT}")
383
+ notify(agent="ops", message="ticket TR-1234 is a showstopper", event_type="ticket-911", correlation_id="${INCIDENT_ID}")
384
+ ```
385
+
386
+ Synchronous alert to a named agent via wired AgentConnector(s). **Contrast with `emit`:** `emit` accumulates into end-of-skill bulk delivery via the `# Output: agent: <name>` lifecycle hook; `notify` fires mid-execution to interrupt or page an agent before the skill completes.
387
+
388
+ - `agent` — target agent id (required)
389
+ - `message` — alert body (optional; defaults to accumulated emissions so far)
390
+ - `event_type` — adopter-defined routing label (optional; flows to `DeliveryMeta.event_type`; overrides `# Event-type:` frontmatter)
391
+ - `correlation_id` — reply-correlation id (optional; required for future `exchange()` / `request_response()` paths)
392
+ - `connectors` — JSON array restricting which wired AgentConnector(s) receive the dispatch (optional)
393
+
394
+ Returns ACK `{agent, dispatched: [{connector, ok, error?}]}` — fire-and-forget callers ignore the binding; check-delivery callers inspect ACK.
395
+
396
+ ### `ask` — interactive prompt
397
+
398
+ ```
399
+ ask(prompt="Approve fix A+B?") -> APPROVED
400
+ ```
401
+
402
+ **Autonomous mode** (cron/event-fired): `ask` fails fast — routes to `else:` or `# OnError:` fallback.
403
+
404
+ **Interactive mode:** response binds to the output variable. **Decline semantics:** when the user response is "no" / "n" / falsey, dependent targets are skipped (treated as soft op-error so `else:` fires).
405
+
406
+ `ask` also acts as a **mutation gate**: any mutation-classified op later in the same target is considered author-confirmed by the preceding `ask`, even without `# Autonomous: true` or per-op `approved="..."` kwarg.
407
+
408
+ ### `shell` — sandboxed or unsafe shell exec
409
+
410
+ **Sandboxed default:**
411
+
412
+ ```
413
+ shell(command="curl -s 'wttr.in/${LOCATION|url}?format=j1'") -> RAW
414
+ shell(command="git status") -> STATUS
415
+ ```
416
+
417
+ Structured-spawn sandbox: one binary per call, args parsed structurally, no shell metacharacter interpretation. The structural constraints ARE the security model. stdout binds; non-zero exit → op-error routed through `else:` / `# OnError:`.
418
+
419
+ **Unsafe mode:**
420
+
421
+ ```
422
+ shell(command="for i in $(seq 1 10); do echo $i; done", unsafe=true) -> R
423
+ shell(command="curl -s example.com | jq '.field' > /tmp/out", unsafe=true)
424
+ ```
425
+
426
+ - Lint flags every `unsafe=true` call as tier-2.
427
+ - Runtime refuses with `UnsafeShellDisabledError` unless deployment sets `runtime.enable_unsafe_shell = true` (default `false`). Compile-time `unsafe-shell-disabled` tier-1 catches at authoring.
428
+ - Audit-visible at every fire.
429
+
430
+ Bash's `$(command)` and arithmetic `$((expr))` pass through to bash without escape because skillscript's substitution is braced (`${VAR}`).
431
+
432
+ ### `file_read` / `file_write` — file I/O
433
+
434
+ ```
435
+ file_read(path="/tmp/state.json") -> STATE
436
+ file_write(path="/tmp/report.md", content="${REPORT}", approved="nightly sweep deliverable")
437
+ ```
438
+
439
+ `file_read` is read-only (always allowed). `file_write` is mutation-classified — requires `# Autonomous: true` declaration on the skill OR per-call `approved="..."` kwarg OR a preceding `ask` gate in the same target. `mkdir -p` semantics for the parent directory.
440
+
441
+ `unconfirmed-mutation` lint enforces the mutation-classification rule.
442
+
443
+ ### `inline` — data-skill compile-time inline
444
+
445
+ ```
446
+ brief:
447
+ $ llm prompt="${VOICE_RULES} Now write a one-line status:" model=qwen -> RESULT
448
+ inline(skill="voice-rules")
449
+ ```
450
+
451
+ 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. `skillfile audit` detects stale recompiles when a referenced data skill changes.
452
+
453
+ See Composition section for the distinction between `inline` (compile-time), `execute_skill` (in-skill runtime call), and dispatched skills.
454
+
455
+ ### `execute_skill` — composition runtime call
456
+
457
+ ```
458
+ classify:
459
+ execute_skill(skill_name="classifier", inputs={"text": "${INPUT}"}) -> VERDICT
460
+ ```
461
+
462
+ Runtime-resolved against the SkillStore. Recursion-depth-guarded (default 10).
463
+
464
+ ---
465
+
466
+ ## External MCP dispatch
467
+
468
+ Calls a tool through a configured connector. Connector name resolves against `connectors.json`. Output binds via optional `-> VAR`. Adopter-side contract details + connector wiring conventions live in the adopter playbook.
469
+
470
+ ### Two dispatch forms — flat and dotted
471
+
472
+ Both forms are first-class. Neither is canonical; choose by what makes the call site clearer.
473
+
474
+ **Flat-name dispatch** is the common case. The tool name resolves against the wired connector (most often the `primary`/`default` connector's tool list, or a dedicated entry per tool):
475
+
476
+ ```
477
+ $ ticketing_search query="project:INFRA" -> R
478
+ $ llm prompt="${INPUT}" -> V
479
+ $ memory mode=fts query="..." limit=5 -> M
480
+ ```
481
+
482
+ **Dotted-prefix dispatch** is the explicit-routing escape hatch — useful when multiple connectors expose tools with overlapping names, or when an adopter wants the connector identity visible at the call site for audit clarity:
483
+
484
+ ```
485
+ $ ticketing.search query="project:INFRA" -> R
486
+ $ memory.query_memories query="..." -> M
487
+ ```
488
+
489
+ Parser rule: the text before the dot is the connector name (must match an entry in `connectors.json`); the rest is the tool + args.
490
+
491
+ ### Worked examples
492
+
493
+ ```
494
+ $ ticketing_search query="project:INFRA state:Open" limit=20 -> ISSUES
495
+ $ llm prompt="Classify: ${INPUT}" -> VERDICT
496
+ $ memory mode=fts query="${TOPIC}" limit=5 -> RESULTS
497
+ $ memory_write content="${SUMMARY}" recipients=["oncall"] approved="morning roundup, 2026-05-25" -> ACK
498
+ ```
499
+
500
+ Tool args are unconstrained `key=value` pairs — the connector forwards them to the underlying MCP tool. If a dispatched call returns `isError: true`, the executor throws via `makeOpError`, which routes through `else:` / `# OnError:` machinery. The inner tool's error text is preserved in `result.errors[]`.
501
+
502
+ **Substrate-neutrality.** Connector names like `$ llm`, `$ memory`, `$ ticketing_search` are NOT reserved or built-in — they're whatever the adopter declares in `connectors.json` (substrate config). Bridges for `$ llm` and `$ memory` / `$ memory_write` auto-wire only when the adopter's substrate config sets `substrate.local_model` / `substrate.memory_store` respectively. See the adopter playbook for the full substrate config reference.
503
+
504
+ **Unknown connector** → tier-1 `unknown-connector` lint with the list of wired connector names.
505
+
506
+ **Unquoted-substitution lint** (`unquoted-substitution-in-kwarg-value`, tier-2): fires when `$ tool 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}"`.
507
+
508
+ ---
509
+
510
+ ## Per-op gating
511
+
512
+ Mutation ops require an authorization signal. The signal is per-op, not a mode binary.
513
+
514
+ **Mutation-classified ops:**
515
+ - `file_write(...)` (runtime-intrinsic)
516
+ - `$ memory_write ...` and any MCP connector entry declared `"mutating": true` in `connectors.json`
517
+ - `shell(command=..., unsafe=true)` (always mutation-classified)
518
+ - `shell(command=...)` with destructive verb (rm, mv, dd, mkfs, etc. — heuristic list)
519
+ - `$ <tool>` matching the mutating-verb regex
520
+
521
+ **Read-only ops (always allowed, no authorization needed):**
522
+ - `file_read`, `emit`, `notify`, `ask`, `inline`, `execute_skill`
523
+ - `shell(command=...)` with read-only verb
524
+ - `$ <connector> ...` against tools declared `mutating: false` (or unspecified, default false for query-shaped tools)
525
+ - `$set`, `$append`
526
+
527
+ **Authorization signals (any one suffices):**
528
+ - `# Autonomous: true` in skill frontmatter — author-level: "this skill is authorized to mutate state during its run." Bypasses lint everywhere in the skill.
529
+ - `approved="<reason>"` kwarg per-op — call-site-level: "this specific op is authorized." The string is required (forces author intent); value not parsed semantically — presence is what matters.
530
+ - Preceding `ask(prompt="...")` call in the same target — gates any mutation op that follows.
531
+
532
+ ```
533
+ # Authorized via skill-level flag
534
+ # Skill: nightly-sweep
535
+ # Autonomous: true
536
+ # Triggers: cron:"0 8 * * *"
537
+
538
+ deliver:
539
+ file_write(path="/tmp/sweep.md", content="${REPORT}") # no approved= needed
540
+ $ memory_write content="${REPORT}" recipients=["oncall"] # no approved= needed
541
+ ```
542
+
543
+ ```
544
+ # Authorized per-call (no # Autonomous: true)
545
+ # Skill: ad-hoc-snapshot
546
+
547
+ deliver:
548
+ file_write(path="/tmp/snap.json", content="${DATA}",
549
+ approved="manual snapshot requested 2026-05-25")
550
+ ```
551
+
552
+ ```
553
+ # Authorized via inline ask gate
554
+ # Skill: interactive-flush
555
+ # Status: Approved
556
+
557
+ flush:
558
+ ask(prompt="Flush cache? (y/n)") -> OK
559
+ shell(command="rm -rf /var/cache/foo") # no approved= needed; ask gates it
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Op grammar summary
565
+
566
+ | Class | Op | Shape | Binding |
567
+ |---|---|---|---|
568
+ | Mutation | `$set` | `$set NAME = value` (with `${VAR}` interpolation at bind) | NAME (no arrow) |
569
+ | Mutation | `$append` | `$append VAR <value>` (type-dispatched: list element / string concat) | VAR (no arrow) |
570
+ | Runtime-intrinsic | `emit` | `emit(text="...")` | none |
571
+ | Runtime-intrinsic | `notify` | `notify(agent="...", [message=...], [event_type=...], [correlation_id=...]) -> ACK` | optional |
572
+ | Runtime-intrinsic | `ask` | `ask(prompt="...") -> R` | required |
573
+ | Runtime-intrinsic | `inline` | `inline(skill="<name>")` | none (compile-time) |
574
+ | Runtime-intrinsic | `execute_skill` | `execute_skill(skill_name="...", inputs={...}) -> R` | optional |
575
+ | Runtime-intrinsic | `shell` | `shell(command="...", [unsafe=true], [approved="..."]) -> R` | optional |
576
+ | Runtime-intrinsic | `file_read` | `file_read(path="...") -> R` | required |
577
+ | Runtime-intrinsic | `file_write` | `file_write(path="...", content="...", [approved="..."])` | none |
578
+ | External MCP | `$ <connector>` | `$ <name>[.<tool>] kwarg=value, ... [-> R]` | optional |
579
+
580
+ ---
581
+
582
+ ## Legacy syntax (deprecated, grace period)
583
+
584
+ These symbol-forms shipped in earlier versions and still compile during the grace period with tier-2 `deprecated-symbol-op` warnings. New skills should use the canonical forms.
585
+
586
+ | Deprecated | Canonical replacement |
587
+ |---|---|
588
+ | `~ prompt="..."` | `$ llm prompt="..."` |
589
+ | `> mode=... query=...` | `$ memory mode=... query=...` |
590
+ | `@ <command>` | `shell(command="...")` |
591
+ | `@ unsafe <command>` | `shell(command="...", unsafe=true)` |
592
+ | `! <text>` | `emit(text="<text>")` |
593
+ | `?? "<prompt>" -> R` | `ask(prompt="<prompt>") -> R` |
594
+ | `& <data-skill-name>` | `inline(skill="<data-skill-name>")` |
595
+ | `$(VAR)` (legacy substitution) | `${VAR}` |
596
+ | `(approved: "reason")` trailer | `approved="reason"` kwarg |
597
+
598
+ The symbol-per-op design was deprecated when verb-word ops in function-call shape proved more author-friendly (training-corpus alignment + human-reviewability). Removal lands in a future version; until then, the canonical surface is the recommended form.
599
+
600
+ ## Variable resolution — ${VAR} canonical, substitution + ambient refs + # Requires: cascade
601
+
602
+ Skillscript supports four tiers of variables, each with distinct resolution timing and scope. Substitution uses **`${VAR}` as the canonical form**. The legacy `$(VAR)` form (parentheses) continues to compile during the grace period; see Ops Reference legacy syntax section for the deprecation map.
603
+
604
+ ## Substitution syntax — `${VAR}` canonical
605
+
606
+ ```
607
+ emit(text="Hello, ${USER.login}!")
608
+ $ memory mode=fts query="${TOPIC}" limit=5 -> R
609
+ $set REPORT = "Triage for ${PROJECT} (${ISSUES|length} open):\n"
610
+ ```
611
+
612
+ Field access: `${VAR.field}`, `${VAR.nested.field}`. Filter chain: `${VAR|filter:"arg"|filter2}`. See Pipe filters section for the filter catalog.
613
+
614
+ The braced form matches bash double-quoted assignment conventions (trained-corpus alignment) and removes substitution-collision with bash's `$(command)` inside `shell(command=..., unsafe=true)`.
615
+
616
+ ## Tier 1: Ambient
617
+
618
+ Injected automatically at runtime; never declared by the author.
619
+
620
+ | Var | Value |
621
+ |-----|-------|
622
+ | `${NOW}` | ISO-8601 timestamp at op-dispatch time |
623
+ | `${USER}` | The configured user identity |
624
+ | `${SESSION_CONTEXT}` | Current session-scope context (project/entity/etc., substrate-defined) |
625
+ | `${TRIGGER_TYPE}` | What event fired this skill |
626
+ | `${TRIGGER_PAYLOAD}` | Event-specific data |
627
+ | `${EVENT.*}` | Event-payload fields populated by the trigger source |
628
+ | `${ERROR_CONTEXT}` | In `# OnError:` fallback skills: type + target where failure occurred |
629
+
630
+ 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.
631
+
632
+ For cron and session triggers, the scheduler injects time-offset ambient fields onto `${EVENT.*}`:
633
+ - `${EVENT.fired_at}` — milliseconds since Unix epoch (raw number)
634
+ - `${EVENT.fired_at_unix}` — seconds since Unix epoch (raw number)
635
+ - `${EVENT.fired_at_plus_1h_unix}` — `fired_at_unix + 3600`
636
+ - `${EVENT.fired_at_plus_1d_unix}` — `fired_at_unix + 86400`
637
+ - `${EVENT.fired_at_plus_7d_unix}` — `fired_at_unix + 604800`
638
+
639
+ These let skill bodies compute `expires_at` and similar bounded-lifetime values without arithmetic in op kwargs. For ISO-formatted rendering of any epoch value, see the `|isodate` filter.
640
+
641
+ Additional ambient refs may be injected based on connector configuration (e.g., a vault-backed memory connector may expose `${VAULT_ROOT}`). Connectors section documents which ambient refs each connector contributes.
642
+
643
+ ## Tier 2: Input
644
+
645
+ Required at invocation; declared in `# Vars:` without a default. Compile fails cleanly if missing.
646
+
647
+ ```
648
+ # Vars: NOTE_PATH, TOPIC
649
+ ```
650
+
651
+ ## Tier 3: Default
652
+
653
+ Optional input with fallback declared inline.
654
+
655
+ ```
656
+ # Vars: FORMAT=prompt, UNITS=imperial
657
+ ```
658
+
659
+ Bracketed list literals supported (`# Vars: TAGS=[a, b, c]`).
660
+
661
+ **Parser convention:** comma splitting in `# Vars:` respects bracket depth. Commas inside `[]`, `()`, `{}` do not terminate values. `# Vars: TAGS=[a, b], MODE=fast` parses as two declarations (`TAGS=[a, b]` and `MODE=fast`); the inner comma is preserved as a list element separator.
662
+
663
+ ## Tier 4: Local
664
+
665
+ Bound to a previous target's output mid-execution. Two forms:
666
+ - `${target.output}` — the bound output of a target
667
+ - `${VAR}` — an explicit `-> VAR` binding from any op
668
+ - `${target.output.field}` or `${MEMORY.field}` — dotted field access into structured output
669
+
670
+ **Field access resolution tiers** for `${MEMORY.field}`:
671
+ 1. Core `PortableMemory` fields (id, summary, detail, score)
672
+ 2. Curated substrate subset (thread_status, pinned, confidence, domain_tags, payload_type, knowledge_type, recipients, expires_at, created_at, agent_id, vault)
673
+ 3. `metadata.X` for everything else
674
+ 4. Ambient passthrough as literal `${MEMORY.field}` if unresolved
675
+
676
+ **Missing-field opt-out:** `${MEMORY.field|fallback:"-"}` coalesces to the literal when the field doesn't resolve. See Pipe filters for the full `|fallback:` semantics.
677
+
678
+ ## Resolution order
679
+
680
+ In `compileSkill`, variables resolve in priority order:
681
+ 1. Caller inputs (passed in at compile time)
682
+ 2. `# Requires:` cascade
683
+ 3. `# Vars:` defaults
684
+ 4. Ambient passthrough (left as `${NAME}` for runtime substitution)
685
+ 5. Missing → compile error
686
+
687
+ ## `# Requires:` cascade
688
+
689
+ Pulls values from the configured data-source backend at compile time. One declaration per line. Both `→` (Unicode) and `->` (ASCII) accepted.
690
+
691
+ ```
692
+ # Requires: user-var:location -> LOCATION (fallback: ip-based)
693
+ # Requires: system-var:morning-brief-delivered -> DELIVERED (fallback: false)
694
+ ```
695
+
696
+ Resolution cascade by namespace:
697
+ - `user-var:<key>` — `user-var:<key>` record → `user-profile.<key>` JSON key → declared fallback
698
+ - `system-var:<key>` — `system-var:<key>` record → declared fallback (no profile tier)
699
+
700
+ Lookups query data records in the calling agent's private scope, filtered by tag, respecting expiration. Caller-supplied `# Vars:` inputs short-circuit the cascade for any matching target name. The specific backend lookup semantics (DB query, file read, KV lookup) are defined by the configured data-source connector.
701
+
702
+ **Vars-namespace conventions** (data records, private scope):
703
+ - `user-profile` — single JSON blob per agent, no expiry, static facts
704
+ - `user-var:<key>` — dynamic per-key record, typically with expiration
705
+ - `system-var:<key>` — agent/process state flags
706
+
707
+ ## `$set` — bind-time interpolation
708
+
709
+ The `$set` op binds a value to a variable at runtime. Compiler-side outer-quote stripping. `${REF}` substitutions in the RHS string resolve at bind time; the bound value is the resolved string. Mirrors bash double-quoted assignment.
710
+
711
+ ```
712
+ $set RESULT = ""
713
+ $set MODE = "production"
714
+ $set GREETING = "Hello, ${USER.login}!" # interpolates at bind
715
+ $set FOUND = []
716
+ ```
717
+
718
+ Missing-ref in the RHS produces a tier-1 runtime error.
719
+
720
+ ## Scoping rules
721
+
722
+ - `# Vars:` declarations are skill-global (visible to all targets)
723
+ - `-> VAR` bindings are skill-global (visible to all targets after the op runs)
724
+ - `foreach IDENT in EXPR:` iterator vars are loop-local — `$set` bindings inside the loop don't persist after the loop ends
725
+ - Target outputs (`${target.output}`) are accessible after the target completes
726
+
727
+ ## Pipe filters — url, shell, json, trim, fallback, isodate
728
+
729
+ Pipe filters apply transforms to resolved variables before substitution. Syntax: `${VAR|filter}` or `${VAR|filter:"arg"}` for parameterized filters. Filters operate at compile time for static values; for runtime-bound variables, filters apply at substitution time.
730
+
731
+ ## Shipped filters
732
+
733
+ | Filter | Effect | Example | Output |
734
+ |--------|--------|---------|--------|
735
+ | `url` | `encodeURIComponent(value)` | `${location|url}` for `"Asheville, NC"` | `Asheville%2C%20NC` |
736
+ | `shell` | POSIX single-quote escape with outer quotes | `${arg|shell}` for `it's safe` | `'it'\''s safe'` |
737
+ | `json` | `JSON.stringify(value)` | `${payload|json}` for `{k:"v"}` | `"{\"k\":\"v\"}"` |
738
+ | `trim` | Whitespace trim | `${VERDICT|trim}` for `"urgent\n"` | `urgent` |
739
+ | `length` | Count of items (array) or characters (string) | `${ITEMS|length}` for `["a","b","c"]` | `3` |
740
+ | `fallback:"X"` | Coalesce on missing/undefined ref | `${VAR.missing|fallback:"-"}` | `-` |
741
+ | `isodate` | Epoch seconds → ISO-8601 timestamp | `${EPOCH|isodate}` for `1779660000` | `2026-05-24T22:00:00.000Z` |
742
+
743
+ ### `length` semantics
744
+
745
+ - Arrays → number of elements
746
+ - Strings → number of characters
747
+ - Non-array/non-string values (number, null, undefined, plain object) → runtime `TypeMismatchError`
748
+
749
+ Strings that hold JSON arrays get the same tolerance as `in`/`not in` RHS: if the string JSON-parses to an array, the array length is returned.
750
+
751
+ Pairs naturally with the numeric comparison operators (see Conditionals section):
752
+
753
+ ```
754
+ $ memory mode=fts query="urgent" -> ITEMS
755
+ if ${ITEMS|length} > 5:
756
+ emit(text="Mailbox is getting crowded")
757
+ ```
758
+
759
+ 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.
760
+
761
+ ### `fallback:"X"` semantics
762
+
763
+ 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.
764
+
765
+ ```
766
+ emit:
767
+ emit(text="present: ${PRESENT|fallback:\"missing\"}") # → "hello" (PRESENT is bound)
768
+ emit(text="missing: ${NOT_DECLARED|fallback:\"-\"}") # → "-" (NOT_DECLARED isn't)
769
+ emit(text="nested: ${ISSUE.customFields.Assignee|fallback:\"unassigned\"}")
770
+ ```
771
+
772
+ **Why filter-shape, not ref-level `(fallback:)`.** Op-level `(fallback: ...)` exists on `$` dispatch for **error recovery** (dispatch happened, failed). Ref-level `|fallback:` is **coalesce** (lookup found nothing). 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.
773
+
774
+ **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.
775
+
776
+ ### `isodate` semantics
777
+
778
+ Converts a Unix epoch-seconds value to an ISO-8601 timestamp string. Pairs with `${NOW}` (ISO-8601 by default) and `${EVENT.fired_at_unix}` (raw epoch seconds, per its name).
779
+
780
+ ```
781
+ show:
782
+ emit(text="Now (already ISO): ${NOW}") # → 2026-05-24T23:34:15.859Z
783
+ emit(text="Trigger fire (ISO): ${EVENT.fired_at_unix|isodate}") # → 2026-05-24T23:34:15.000Z
784
+ emit(text="Static epoch: ${SOME_EPOCH|isodate}") # → 2026-05-24T22:00:00.000Z
785
+ ```
786
+
787
+ Input is interpreted as Unix epoch seconds. Non-numeric input produces runtime error. For millisecond inputs, divide first or use a wrapping op.
788
+
789
+ ## Filter chaining
790
+
791
+ Filters chain left-to-right. The output of each filter becomes input to the next.
792
+
793
+ ```
794
+ ${VERDICT|trim|json}
795
+ ```
796
+
797
+ First trims whitespace, then JSON-stringifies the result.
798
+
799
+ ## Filter use in conditionals
800
+
801
+ Filters may appear on the LHS of conditional expressions. Useful for whitespace-tolerant equality checks against LocalModel output (which often has trailing newlines).
802
+
803
+ ```
804
+ if ${VERDICT|trim} == "urgent":
805
+ ...
806
+ if ${VAR.maybe|fallback:"-"} == "-":
807
+ emit(text="nothing there")
808
+ ```
809
+
810
+ Filter chains in conditions all work in conditional context.
811
+
812
+ ## Filter use in `in` / `not in` set membership
813
+
814
+ Filters may appear on the LHS of `in` / `not in` checks (the comparison side). The RHS must resolve to an array at runtime.
815
+
816
+ ```
817
+ if ${M.id|trim} in ${SEEN}:
818
+ emit(text="already processed")
819
+ ```
820
+
821
+ ## Filter use in numeric comparison
822
+
823
+ Filters may appear on either side of `<`, `>`, `<=`, `>=` comparisons. `|length` is the canonical companion — most numeric-threshold patterns are "more than N items" rather than arithmetic on raw values.
824
+
825
+ ```
826
+ if ${ITEMS|length} > 5:
827
+ ...
828
+ elif ${BODY|length} > 1000:
829
+ ...
830
+ ```
831
+
832
+ ## Error handling
833
+
834
+ Unknown filter on a resolved variable produces a tier-1 `unknown-filter` compile error. Catches both bare (`|unknown`) and colon-positional (`|unknown:"arg"`) shapes. Filter chains that fail at runtime (e.g., `|json` on a non-serializable value, `|length` on a number, `|isodate` on a non-numeric value) produce op errors that route through `else:` / `# OnError:` machinery.
835
+
836
+ Bare `${NAME}` without a filter is unchanged.
837
+
838
+ ## Pending filters
839
+
840
+ Several filters are planned but not yet shipped:
841
+
842
+ | Filter | Effect | Use case |
843
+ |--------|--------|----------|
844
+ | `head:N` | First N lines | Truncate long output for embedding in prompts |
845
+ | `tail:N` | Last N lines | Recent log entries |
846
+ | `lines:M-N` | Range of lines | Specific slice |
847
+ | `field:N` | Nth whitespace-separated field | Awk-like extraction |
848
+ | `summary` | One-line abbreviation | Compress for human-facing emissions |
849
+ | `pluck:<field>` | Project array of objects to array of field values | Paired with `in`/`not in` for dedup-by-id workflows |
850
+ | `join:"<sep>"` | List → string with separator | Filter-shape alternative to string `$append`; reconsider if filter-chain demand surfaces |
851
+ | `isodate_ms` | Epoch ms → ISO-8601 | Companion to `|isodate`; defer until demand |
852
+
853
+ `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.
854
+
855
+ `join:"<sep>"` is parked: string-typed `$append` + bind-time `$set` interpolation (bash-shaped pair) is the primitive way to compose lists into strings. `|join:` is the filter-shape alternative; reconsider if real filter-chain demand surfaces.
856
+
857
+ ## Composition philosophy
858
+
859
+ 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 ~85% of real-world string-shaping needs; the pending set extends to slicing and array projection.
860
+
861
+ `length`, `fallback:`, and `isodate` were all added in response to cold-author harness signal — authored skills demonstrated the gap was load-bearing before each filter shipped.
862
+
863
+ ## Conditionals & iteration — if/elif/else, foreach, supported operators
864
+
865
+ Skillscript supports narrow conditionals and bounded iteration. Both are deliberately constrained — composition over expressiveness.
866
+
867
+ ## Conditionals
868
+
869
+ `if COND:` / `elif COND:` / `else:` chain. Supported condition shapes:
870
+
871
+ ### Truthy
872
+
873
+ ```
874
+ if ${VAR}:
875
+ emit(text="VAR was set and non-empty")
876
+ ```
877
+
878
+ ### Equality
879
+
880
+ `==` and `!=` against either quoted string literals or another `${...}` ref. Filters and dotted-field access are permitted on either side.
881
+
882
+ ```
883
+ if ${VERDICT} == "urgent":
884
+ ...
885
+ elif ${VERDICT} != "quiet":
886
+ ...
887
+ ```
888
+
889
+ ```
890
+ if ${FP|trim} == ${LAST_FP|trim}:
891
+ emit(text="no change since last scan")
892
+ elif ${M.id} != ${LAST_ID}:
893
+ emit(text="drift detected")
894
+ ```
895
+
896
+ The ref-vs-ref form is the canonical change-detection pattern. Both sides resolve to strings at evaluation time; equality is byte-for-byte after filter application. No type coercion — `${N} == "42"` compares the string form of N against the literal `"42"`, even if N is "numeric" elsewhere in the connector layer.
897
+
898
+ ### Set membership
899
+
900
+ ```
901
+ if ${M.id|trim} in ${SEEN}:
902
+ emit(text="already processed")
903
+ elif ${M.id} not in ${SEEN}:
904
+ $ memory_write content="..." approved="dedup" -> R
905
+ ```
906
+
907
+ Both sides are explicit refs. RHS must resolve to an array at runtime; clean error otherwise. LHS-undefined evaluates to `false` for both polarities. Optional filter on LHS.
908
+
909
+ **JSON-string tolerance on RHS**: if the RHS resolves to a *string* that successfully JSON-parses to an array, the parsed array is used. This accommodates the canonical pattern where the array comes from a `$ llm` call that prompted for JSON output:
910
+
911
+ ```
912
+ $ llm prompt="List the URGENT memory IDs as a JSON array of strings. Items: ${M|json}" -> SEEN
913
+
914
+ foreach M in ${MEMORIES}:
915
+ if ${M.id} in ${SEEN}:
916
+ emit(text="flagged urgent")
917
+ ```
918
+
919
+ `${SEEN}` resolves to a string like `["abc", "def"]`; runtime JSON-parses, sees an array, uses it. Strings that don't JSON-parse to an array still error per the strict rule — only valid JSON arrays get the tolerance.
920
+
921
+ ### Numeric comparison
922
+
923
+ `<`, `>`, `<=`, `>=` in `if`/`elif` conditions. Both operands resolve as strings (same as equality), then attempt numeric coercion. If both coerce, the comparison runs numerically. If either fails to coerce, runtime `TypeMismatchError`.
924
+
925
+ ```
926
+ if ${DELTA} > ${THRESHOLD}:
927
+ emit(text="ALERT: dropped past threshold")
928
+ elif ${COUNT} <= 0:
929
+ emit(text="No items returned")
930
+ ```
931
+
932
+ Filters and dotted-field access work on either side, same as equality. The `|length` filter (see Pipe filters section) is the canonical companion — `${LIST|length} > 5` is the natural "more than five items" pattern:
933
+
934
+ ```
935
+ $ memory mode=fts query="urgent" -> ITEMS
936
+ if ${ITEMS|length} > 5:
937
+ emit(text="Mailbox is getting crowded")
938
+ ```
939
+
940
+ **Decimal precision.** Coercion uses native number parsing — `5.00` and `5` both coerce to `5`. Skill authors should keep thresholds at the precision they care about; numeric comparison does not preserve trailing-zero string form.
941
+
942
+ **Why comparison, not arithmetic.** The orchestration carve-out: comparison operators land in the language because *conditionals are orchestration decisions*. Arithmetic operators (`+`, `-`, `*`, `/`) and aggregates (`min`, `max`, `sum`) are deliberately NOT in the grammar — those produce values, which is computation, which belongs in tools. The line is "comparison is orchestration; arithmetic is computation."
943
+
944
+ If you need to compute a value to compare against, the computation goes in a tool that returns the computed value; the skill compares the returned value. Skills stay orchestration-shaped.
945
+
946
+ ### Logical connectives: `and` / `or` / `not`
947
+
948
+ Compound conditions via standard boolean connectives.
949
+
950
+ ```
951
+ classify:
952
+ $ llm prompt="..." model=qwen -> VERDICT
953
+ if ${VERDICT|trim} == "urgent" and ${SEVERITY|trim} > "5":
954
+ emit(text="escalate")
955
+ elif ${VERDICT|trim} == "urgent" or ${SEVERITY|trim} > "8":
956
+ emit(text="flag")
957
+ else:
958
+ emit(text="noted")
959
+ ```
960
+
961
+ **Precedence** (tightest to loosest):
962
+ 1. Comparison: `==` / `!=` / `<` / `>` / `<=` / `>=` / `in` / `not in`
963
+ 2. Unary: `not`
964
+ 3. Binary: `and`
965
+ 4. Binary: `or`
966
+
967
+ `a and b or c` parses as `(a and b) or c`. Standard convention. Parentheses available for explicit grouping when default precedence isn't what you want: `(a or b) and c`.
968
+
969
+ **Short-circuit semantics.** `if ${X} == "ok" and ${MAYBE_UNRESOLVED}` does NOT evaluate the RHS if the LHS already determined the result (false). Matches every other language; required for the "validate-then-access" pattern.
970
+
971
+ **Falsy check via `not`.** `not ${VAR}` closes the gap where you'd previously have to enumerate `if ${VAR} == "":` / `if ${VAR} == "false":` / `if ${VAR} == "0":` separately.
972
+
973
+ ```
974
+ mailbox_check:
975
+ $ memory mode=fts query="addressed:perry" limit=10 -> MAILBOX
976
+ if not ${MAILBOX}:
977
+ emit(text="empty mailbox today")
978
+ elif ${MAILBOX|length} > 5:
979
+ emit(text="triage backlog")
980
+ ```
981
+
982
+ **De Morgan via parens:** `if not (${A} and ${B}):` works as expected.
983
+
984
+ **`not` with membership:** `not ${X} in ${LIST}` parses as `not (${X} in ${LIST})` — membership-tighter-than-not convention.
985
+
986
+ **Lint interaction.** Existing `undeclared-var` lint catches references to truly-undeclared vars at compile time. Short-circuit affects only runtime evaluation — "the var is declared, but might not be bound at this evaluation point" is the runtime-only case.
987
+
988
+ ### What's NOT supported
989
+
990
+ - *No arithmetic ops* — no `+`, `-`, `*`, `/`. Arithmetic produces values; values come from tools. Comparison only (see Numeric comparison above).
991
+ - *No aggregate functions* — no `min`, `max`, `sum`, `mean`. Same reasoning.
992
+ - *No filter math* — filters apply to substitution, not to condition evaluation arithmetic.
993
+ - *No single-`=` assignment-in-condition* — this isn't a feature, it's a parse error.
994
+
995
+ **Common parse error: single `=` in conditional position.** A single `=` in an `if`/`elif` condition is a parse error with a specific diagnostic:
996
+
997
+ ```
998
+ error: '=' is not valid in a condition; use '==' for equality
999
+ if ${VERDICT} = "urgent":
1000
+ ^
1001
+ rewrite as: if ${VERDICT} == "urgent":
1002
+ ```
1003
+
1004
+ The grammar doesn't admit single-`=` in condition position at all — the parser catches the construction via a specific error production rather than failing with a generic "syntax error."
1005
+
1006
+ ### Disambiguation: `else:` after target body vs `else:` after `if:`
1007
+
1008
+ Both shapes use the keyword `else:`. Distinguished by parser scope-stack at parse time:
1009
+ - `else:` after a target's primary body → error handler (runs when any op in the body errors). See Error handling section.
1010
+ - `else:` after `if:` / `elif:` chain → conditional branch.
1011
+
1012
+ Both can coexist in the same target.
1013
+
1014
+ ## Iteration: `foreach`
1015
+
1016
+ `foreach IDENT in EXPR:` block iterates over a list, binding `IDENT` to each item per iteration. Body indented under the header; indent-based dedent returns to outer scope.
1017
+
1018
+ ```
1019
+ foreach M in ${RESULTS}:
1020
+ emit(text="Processing ${M.id} — ${M.summary}")
1021
+ if ${M.id|trim} not in ${SEEN}:
1022
+ $ memory_write content="${M.summary}" approved="dedup" -> ACK
1023
+ ```
1024
+
1025
+ ### Iterator vars
1026
+
1027
+ `${M}` and `${M.field}` pass through ambient at compile; runtime substitutes per iteration. Dotted field access against `PortableMemory` shape applies (core fields → curated subset → metadata). Indexed access (`${LIST.0}`, `${LIST.0.id}`) also works on bound results.
1028
+
1029
+ ### Loop-local scope (and the accumulator exception)
1030
+
1031
+ `$set` bindings inside the loop don't persist after the loop ends. Each iteration starts fresh from the loop binding.
1032
+
1033
+ **`$append` is the exception**. Appending to a list-typed variable declared in the *enclosing* scope (target body or `# Vars:`) mutates the outer binding, surviving across iterations:
1034
+
1035
+ ```
1036
+ walk:
1037
+ $set FOUND = []
1038
+ foreach M in ${MESSAGES}:
1039
+ if ${M.id} not in ${FOUND}:
1040
+ $append FOUND ${M.id}
1041
+ emit(text="Collected: ${FOUND|length} novel items")
1042
+ ```
1043
+
1044
+ See the Ops reference `$append` section for the full lint rules (`uninitialized-append`, `foreach-local-accumulator-target`, `append-to-non-list`).
1045
+
1046
+ ### What's NOT supported
1047
+
1048
+ - *No `while` loop* — iteration is bounded by the iterable's length. Unbounded loops are not expressible. (See "Not yet implemented, but planned" at top — `while` is planned.)
1049
+ - *No `break` or `continue`* — every iteration runs to completion. Filter the iterable beforehand if you need exclusion.
1050
+ - *No nested-loop variable capture* — inner-loop `$set` doesn't escape to outer scope.
1051
+ - *No `parallel foreach`* — iteration is serial. `$append` inside a future `parallel foreach` is a tier-1 error; semantics deferred to whenever parallel foreach ships.
1052
+
1053
+ ## Composition philosophy
1054
+
1055
+ The grammar is deliberately narrow. The threshold for adding new grammar is "an authored skill demonstrates the gap is load-bearing." Composition through nested blocks + filter chains covers most real cases.
1056
+
1057
+ The carve-out is principled: *comparison and logical connectives* land because conditionals ARE orchestration decisions; *arithmetic and aggregates* stay out because they produce values, which belong in tools. Future grammar extensions follow the same discipline: surfaced by real authoring need, not by speculative completeness, and only if they sit on the orchestration side of the line.
1058
+
1059
+ Authors writing complex conditional logic should consider:
1060
+ - *Push the logic into a `$ llm` call* — let the model classify, return a one-word verdict, branch on equality
1061
+ - *Push the logic into a connector* — wrap the complex check as an MCP tool, dispatch via `$`
1062
+ - *Decompose into multiple skills* via `execute_skill(skill_name=...)` (see Composition section)
1063
+
1064
+ Skills are orchestration, not computation. When the conditional logic feels Turing-complete, the work belongs in a connector.
1065
+
1066
+ ## Triggers — # Triggers: header, declarative + imperative registration, source types
1067
+
1068
+ Triggers declare what events fire a skill autonomously. A skill without triggers must be invoked explicitly (via a compile/execute API call); a skill with triggers fires automatically when matching events occur.
1069
+
1070
+ ## Declarative registration via `# Triggers:` header
1071
+
1072
+ The skill body declares triggers via metadata header. Multiple triggers permitted, comma-separated or one per line.
1073
+
1074
+ ```
1075
+ # Triggers: cron: 0 8 * * *, session: start
1076
+ ```
1077
+
1078
+ On skill write, the runtime's trigger registry parses the header and auto-registers each trigger. Editing the skill body updates registrations.
1079
+
1080
+ ## Imperative registration
1081
+
1082
+ For dynamic, one-shot, or runtime-decided triggers, use the imperative `registerTrigger` API:
1083
+
1084
+ ```
1085
+ registerTrigger({
1086
+ skill_name: "my-skill",
1087
+ source: "cron",
1088
+ name: "55 2 * * *",
1089
+ expires_at: 1779107400 // optional auto-cleanup
1090
+ })
1091
+ ```
1092
+
1093
+ Imperative triggers default to a 30-day expiration (cleanup via expiry sweep). Pass `null` for indefinite retention; author must clean up via the corresponding `unregisterTrigger` API.
1094
+
1095
+ ## Trigger sources
1096
+
1097
+ ### `cron: <expression>` — time-based
1098
+
1099
+ Standard 5-field cron. Sliding-window evaluation by a 30s poll loop. No catch-up replay if the runtime was down at fire time.
1100
+
1101
+ ```
1102
+ # Triggers: cron: 0 3 * * *
1103
+ ```
1104
+
1105
+ ### `session: start | end` — session lifecycle hooks
1106
+
1107
+ Fires when an agent session begins (`session: start`) or ends (`session: end`). The load-bearing primitive for prepping context at session boundaries — a session-start skill produces `agent:` output that prepends to the next inference.
1108
+
1109
+ ```
1110
+ # Triggers: session: start
1111
+ # Output: agent: <agent-name>
1112
+ ```
1113
+
1114
+ ### `event: <event-name>` — runtime-host-emitted events (parse-only)
1115
+
1116
+ Header parses cleanly today but the event bus that would emit `event:` triggers isn't wired yet. Cross-reference: see "Not yet implemented, but planned" at top.
1117
+
1118
+ Example event categories (deployment-defined):
1119
+ - `event: thread.replied` — a thread receives a new reply
1120
+ - `event: mailbox.dangle` — an addressed item expires unprocessed
1121
+ - `event: classifier.flagged` — a background classifier surfaces an urgent finding
1122
+ - (extensible via runtime-host event registration)
1123
+
1124
+ ### `agent-event: <agent>.<event>` — cross-agent event hooks (parse-only)
1125
+
1126
+ Subscribes to another agent's events. Same parse-only status as `event:`.
1127
+
1128
+ ```
1129
+ # Triggers: agent-event: builder.task.completed
1130
+ ```
1131
+
1132
+ ### `file-watch: <path>` — filesystem change (parse-only)
1133
+
1134
+ Fires when the named path changes. Relies on inotify (Linux) or kqueue (macOS) on the host.
1135
+
1136
+ Open spec question: recursive vs directory-only default. Current lean: directory-only by default, opt-in via `file-watch-recursive:` or `file-watch: <path> (recursive)`.
1137
+
1138
+ ### `sensor: <sensor-name>` — external sensor stream (parse-only)
1139
+
1140
+ Extension surface for multimodal inputs — camera, microphone, presence, screen state. Designed as a category distinct from tools: sensors are continuous channels the agent reads but doesn't emit on. Privacy gating is a structural precondition.
1141
+
1142
+ ```
1143
+ # Triggers: sensor: presence
1144
+ ```
1145
+
1146
+ ## Trigger context
1147
+
1148
+ When a skill fires from a trigger, the runtime populates ambient refs accessible inside the skill body:
1149
+
1150
+ - `${TRIGGER_TYPE}` — the trigger source (`cron`, `session`, etc.)
1151
+ - `${TRIGGER_PAYLOAD}` — source-specific data
1152
+ - `${EVENT.*}` — event-payload fields for `event:` / `agent-event:` triggers
1153
+
1154
+ ## Trigger lifecycle
1155
+
1156
+ - **Registration:** declarative via header (auto on skill write) or imperative via the `registerTrigger` API
1157
+ - **Storage:** registered triggers are records owned by the registering agent, indexed by source + name + agent_id + skill_id; the storage backend is connector-defined
1158
+ - **Inspection:** `listTriggers({ skill_name?, agent_id?, source? })` returns the live registry
1159
+ - **Archival:** `unregisterTrigger(trigger_id)` archives the trigger (audit trail preserved); declarative triggers are removed by editing the skill body to drop the declaration
1160
+
1161
+ ## Multiple triggers
1162
+
1163
+ A skill may declare multiple triggers; each fires an independent execution. The compiled output is identical regardless of trigger; the runtime distinguishes via `${TRIGGER_TYPE}`.
1164
+
1165
+ Open spec question: dedup on near-simultaneous fires. If `cron: 0 8 * * *` and `event: user.present` both fire within seconds, the runtime currently runs the skill twice (one per trigger). Author dedups via state if needed.
1166
+
1167
+ ## Output targets — # Output: header, delivery kinds
1168
+
1169
+ The `# Output:` header declares where a skill's result is delivered. Default behavior (no header) is `text` — return string to caller.
1170
+
1171
+ ## Output kinds
1172
+
1173
+ ### `text` (default, bare-only)
1174
+
1175
+ Returns the skill's result as a string to whatever invoked the skill via API or read the compiled prompt artifact. Bare-only — no target accepted; parse error if a target is supplied.
1176
+
1177
+ ```
1178
+ # Output: text
1179
+ ```
1180
+
1181
+ ### `agent: <agent-name>` — augmenting context to a named agent
1182
+
1183
+ The Augmenting-kind delivery. Output prepends to the named agent's next-turn prompt context as augment-kind payload.
1184
+
1185
+ ```
1186
+ # Output: agent: <agent-name>
1187
+ ```
1188
+
1189
+ Used to bring an agent into the next turn pre-shaped — context that would normally require a session-start retrieval is pre-positioned. Wired end-to-end via the runtime host's prompt-prepend surface + a synchronous trigger-fire endpoint with timeout-fallback so the next-turn dispatch isn't blocked on slow skill execution.
1190
+
1191
+ ### `template: <agent-name>` — playbook delivered to a named agent
1192
+
1193
+ The Template-kind delivery. Output renders as a playbook the named agent executes itself — the runtime doesn't dispatch the ops, it hands the agent a recipe to follow.
1194
+
1195
+ ```
1196
+ # Output: template: <agent-name>
1197
+ ```
1198
+
1199
+ Used for reusable recipes: a skill that, when compiled, produces instructions another agent follows.
1200
+
1201
+ ### `file: <path>` — write to file
1202
+
1203
+ Header parses; file router not yet implemented. See "Not yet implemented, but planned" at top.
1204
+
1205
+ ### `none` (bare-only)
1206
+
1207
+ Side-effects only — the skill's purpose is the writes / shell ops it performs, not the returned value. Bare-only; parse error if a target is supplied.
1208
+
1209
+ ```
1210
+ # Output: none
1211
+ ```
1212
+
1213
+ ## Multiple output targets
1214
+
1215
+ A skill may declare multiple output targets, one per line. Each target receives the same content.
1216
+
1217
+ ```
1218
+ # Output: agent: ops-channel
1219
+ # Output: agent: assistant
1220
+ ```
1221
+
1222
+ A morning-brief skill, for example, can deliver to a team-channel agent and to an assistant agent's session-start prompt context simultaneously.
1223
+
1224
+ ## Skill categories — Augmenting / Template / Headless
1225
+
1226
+ The output kind declaration determines the skill's category for discovery purposes (see SkillStore `skill_list` discovery surface):
1227
+
1228
+ | Category | Determined by | Discovery group |
1229
+ |---|---|---|
1230
+ | **Augmenting** | Has `# Output: agent: <name>` declared | `receives` |
1231
+ | **Template** | Has `# Output: template: <name>` declared OR no agent/template output but agent-invokable (no triggers) | `skills` |
1232
+ | **Headless** | Output is `text` / `file:` / `none` AND has autonomous triggers | `headless` (filtered out of default agent discovery) |
1233
+
1234
+ The derivation: ANY `output.kind === "agent"` → Augmenting; else ANY `output.kind === "template"` → Template; else if no autonomous triggers → Template (agent-invokable inference); else → Headless.
1235
+
1236
+ Agent discovery via `skill_list()` defaults to `receives` + `skills` groups. Headless skills are filtered out of the default view (admin views can opt in via `filter: { audience: "all" }`).
1237
+
1238
+ ## Per-kind output value semantics
1239
+
1240
+ Different output kinds consume the skill's execution result differently:
1241
+
1242
+ - **Presentation surfaces** (`agent:`, `template:`) consume joined emissions — all `emit()` ops in the skill body concatenated in execution order
1243
+ - **Programmatic surfaces** (`text`, `file:`) consume the `lastBoundVar` — the most recently bound `-> VAR` value from any op
1244
+
1245
+ Single source of truth in the executor's `perKindOutput()` function; routers stay dumb (just consume what the executor hands them per kind).
1246
+
1247
+ ## Augmenting / Template companion header
1248
+
1249
+ Skills with `agent:` or `template:` output kinds can declare a companion header that rides along with the delivery payload. Optional; has no effect on Headless skills.
1250
+
1251
+ ### `# Event-type: <string>`
1252
+
1253
+ Adopter-defined routing vocabulary; flows to `DeliveryMeta.event_type` on lifecycle-hook deliveries as the frontmatter fallback. `notify(event_type=...)` kwarg takes precedence per-emit.
1254
+
1255
+ ```
1256
+ # Output: agent: perry
1257
+ # Event-type: ticket-911
1258
+ ```
1259
+
1260
+ The receiving agent reads `event_type` for routing decisions ("this is a 911 — surface immediately" vs "this is a routine check — fold into next brief").
1261
+
1262
+ ### Lint coverage
1263
+
1264
+ A tier-2 lint rule `unused-augmenting-header` fires when `# Event-type:` appears on a Headless skill (no `agent:` or `template:` output declared). Headless skills have no AgentConnector dispatch path, so the header would silently no-op — the lint warns the author to either change the output kind or remove the header.
1265
+
1266
+ Legacy `# Delivery-context:` header was renamed to `# Event-type:` for vocab consistency. A tier-2 advisory `legacy-frontmatter-header` fires on the legacy form with a rename suggestion.
1267
+
1268
+ ## Grammar
1269
+
1270
+ - Kinds with no target (`text`, `none`) are bare-only — `# Output: text` is valid, `# Output: text: anything` is a parse error.
1271
+ - Kinds with a target (`agent`, `template`, `file`) require `<kind>: <target>` — `# Output: agent` without a target is a parse error.
1272
+ - Authoring friction-fix: parse errors on bare-only kinds suggest the corrected shape inline.
1273
+
1274
+ ## Output routing failures
1275
+
1276
+ If `# Output: agent: <name>` fires and the wired AgentConnector throws, the delivery routes through `else:` / `# OnError:` machinery. The receipt surface (`agent_delivery_receipts[]`) records the failure for the scheduler to log.
1277
+
1278
+ ## Lifecycle and status — # Status: header, six canonical states, compile + runtime enforcement
1279
+
1280
+ Skillscripts carry an explicit lifecycle state via the `# Status:` header. The compiler and runtime enforce status — a Disabled skillscript cannot fire under any path, regardless of who invokes it.
1281
+
1282
+ ## Header syntax
1283
+
1284
+ ```
1285
+ # Skill: support-response-draft
1286
+ # Status: Approved v1:a1b2c3d4
1287
+ # Description: ...
1288
+ ```
1289
+
1290
+ If `# Status:` is omitted, the default state is **Draft**. This forces authors to explicitly promote a skillscript through its lifecycle rather than relying on "newly written = ready for use."
1291
+
1292
+ **Case normalization:** Status values are accepted case-insensitively on input and stored as canonical form. `# Status: draft`, `# Status: Draft`, `# Status: DRAFT` all parse to canonical `Draft`. This principle applies across all enumerated frontmatter value spaces (see Overview section on Lexical conventions).
1293
+
1294
+ ## The three canonical states
1295
+
1296
+ - **Draft** — being authored or under revision; not ready for production use. Compile warns; runtime refuses unless explicitly invoked with `--force-draft` for the author's own testing. Triggers don't fire under default dispatch.
1297
+ - **Approved** — passed authoring + lint and is ready to fire. The canonical "in use" state. Compile is clean; runtime allows everywhere; declared triggers fire freely. **Requires a hash-token stamp** (see below).
1298
+ - **Disabled** — explicitly off. Compile rejects; runtime rejects; triggers don't fire. Source and version history preserved, but the skillscript cannot execute under any path.
1299
+
1300
+ These three states have crisp, universal operational meaning across every deployment. Every operator understands what each state means; no judgment calls about edge-case distinctions.
1301
+
1302
+ ## Hash-token approval for Approved
1303
+
1304
+ `# Status: Approved` requires a stamped version-hash token: `# Status: Approved v1:<token>` where `<token>` is `f(skill_body)`. Naked `# Status: Approved` (without the stamp) refuses to execute at runtime.
1305
+
1306
+ The runtime verifies the stamp on every execution path:
1307
+ - Trigger-fired dispatch
1308
+ - MCP `execute_skill` invocation
1309
+ - In-skill `$ execute_skill` composition
1310
+ - Compile-time `inline(skill=...)` references
1311
+
1312
+ The stamp closes the gate against tampered or Draft-promoted-without-review skill bodies. Three paths produce a stamped Approved skill:
1313
+
1314
+ 1. **Dashboard approval flow** — human reviewer approves; dashboard stamps the token.
1315
+ 2. **`skill_write` MCP tool auto-stamp** — when an agent writes a skill body declaring `# Status: Approved`, the SkillStore auto-stamps the token on persist (headless-adopter convenience).
1316
+ 3. **Manual stamp** — for unusual cases; details in the adopter playbook.
1317
+
1318
+ Tampered bodies (someone edits the source post-stamp) re-derive a different token and refuse to execute. The hash-token check is the structural lock; the dashboard / `skill_write` flows are the discipline layer.
1319
+
1320
+ ## Compile + runtime behavior table
1321
+
1322
+ | State | Compile | Runtime invocation | Test harness | Default trigger fire |
1323
+ |-------|---------|-------------------|--------------|---------------------|
1324
+ | Draft | warn | refuse (unless `--force-draft`) | allow (with flag) | refuse |
1325
+ | Approved (stamped) | OK | allow | allow | allow |
1326
+ | Approved (unstamped) | warn `approved-without-stamp` | refuse | refuse | refuse |
1327
+ | Disabled | refuse | refuse | refuse | refuse |
1328
+
1329
+ ## Trigger registry interaction
1330
+
1331
+ The trigger registry respects status. A skillscript in Draft or Disabled state has its declared triggers held in a non-firing state — the trigger is registered (visible via `listTriggers`) but the scheduler skips dispatch. This lets authors register triggers while still in Draft mode without risking accidental production fires.
1332
+
1333
+ When a skillscript transitions to Approved (with valid stamp), its triggers activate. When it transitions to Disabled, its triggers deactivate.
1334
+
1335
+ ## State transitions
1336
+
1337
+ Status transitions are freeform — any author with write authority on the skillscript can flip the status by editing the header. Future versions may add transition rules (Draft → Approved with lint-pass requirement; Disabled requiring admin-level permission) once a real authorship-permissions story is in place.
1338
+
1339
+ ## Audit trail
1340
+
1341
+ Status changes are visible via the storage substrate's versioning. For SqliteSkillStore-backed skills, each status transition appends a row to `skill_versions` (see SqliteSkillStore docs). For FilesystemSkillStore-backed skills, status changes show up in git history. The audit trail is part of the substrate, not part of the language.
1342
+
1343
+ ## States considered but not implemented
1344
+
1345
+ Three additional states were considered and deferred. Each is cheap to add later when justified by real operational need:
1346
+
1347
+ - **Test** — distinct "passed compile but not production-ready" state. Today's Draft covers this case (same behavior — refuse to fire under default dispatch). If authors find Draft and Test are operationally distinct in practice, Test ships then.
1348
+ - **Deployed** — distinct "currently shipping" state separate from Approved. Today's Approved + active triggers IS deployed; no operational difference. If a deployment finds Approved-vs-Deployed meaningfully different (e.g., a release-gating workflow that distinguishes "ready" from "live"), Deployed ships then.
1349
+ - **Deprecated** — soft-warn state for "still works but new authoring should use a successor." Deprecation is currently carried in metadata (`deprecated: true` in frontmatter) + a lint warning at invocation sites. When deprecated skills accumulate enough that the metadata pattern is awkward, Deprecated promotes to a first-class state.
1350
+
1351
+ Adding states is additive — existing skills with the three-state model continue to work when new states are added.
1352
+
1353
+ ## Why this matters
1354
+
1355
+ The lifecycle states are the language's answer to operational safety at scale. A traditional "all skillscripts compile and run" model relies on author discipline to keep broken or untested work out of production. Status states enforce the discipline at the language level — a Disabled skill cannot fire even if every author downstream forgets it's broken. The hash-token approval mechanism extends this to: an Approved skill cannot fire if its body was tampered post-approval. The constraint IS the safety story, here as elsewhere.
1356
+
1357
+ ## Open questions
1358
+
1359
+ - **Status + composition.** When a procedural skill references a data skill via `inline(skill=...)`, what happens if the data skill is Disabled? Probable answer: compile-time error if any referenced skill is Disabled.
1360
+ - **Bulk status operations.** "Disable all skills tagged with project:legacy" is a useful operational primitive. May add a `skillscript bulk-status <pattern> <state>` CLI affordance later.
1361
+
1362
+ ## Error handling — else: blocks, # OnError: fallback, op-level fallback values
1363
+
1364
+ Skillscript provides three layers of error handling, working from local to global.
1365
+
1366
+ ## Layer 1: Target-level `else:` block
1367
+
1368
+ Runs if any op in the target's primary body errors. Local to the failing target. Downstream targets that depend on this one can still proceed using whatever the `else:` branch produced.
1369
+
1370
+ ```
1371
+ fetch:
1372
+ $ memory mode=fts query=${TOPIC} limit=5 -> RESULT
1373
+ else:
1374
+ emit(text="retrieval failed, falling back to empty result")
1375
+ $set RESULT = ""
1376
+ ```
1377
+
1378
+ ### Distinguished from conditional `else:`
1379
+
1380
+ The keyword `else:` is shared between two purposes:
1381
+ - Conditional `else:` — appears after `if:` / `elif:` chain inside a target body
1382
+ - Target `else:` — appears as a sibling block after a target's primary body, as an error handler
1383
+
1384
+ The parser's scope-stack discriminates at parse time. Both kinds coexist in the same target.
1385
+
1386
+ ### Constraint
1387
+
1388
+ `else:` blocks may not declare their own error handlers (no nested catch). If an `else:` block fails, the whole target fails through `# OnError:` if present.
1389
+
1390
+ ## Layer 2: Skill-level `# OnError:` header
1391
+
1392
+ Names a fallback skill to invoke if anything in the skill fails — including target-level errors that aren't caught by `else:`, compile errors, or the executing context running out of resources.
1393
+
1394
+ ```
1395
+ # Skill: morning-brief
1396
+ # OnError: morning-brief-degraded
1397
+ ```
1398
+
1399
+ Compile-time existence check — fails clean if the referenced fallback doesn't exist. The fallback skill is itself a skill (same compilation, same execution model) and can do real work (file an issue, post an ack, write a degraded result, etc.).
1400
+
1401
+ The fallback skill receives:
1402
+ - The same inputs as the failing skill
1403
+ - An additional `${ERROR_CONTEXT}` ambient ref containing the error type and the target where it failed
1404
+
1405
+ ### Constraint
1406
+
1407
+ Nested `# OnError:` is *not* supported. If `# OnError: degraded-skill` fires and `degraded-skill` itself errors, the runtime hard-exits with no further fallback. Spec is explicit on this.
1408
+
1409
+ ## Layer 3: Op-level fallback values
1410
+
1411
+ 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.
1412
+
1413
+ ```
1414
+ weather:
1415
+ $ memory mode=fts query="weather ${LOCATION}" limit=1 -> CURRENT (fallback: "weather unavailable")
1416
+ $ llm prompt="Summarize: ${CURRENT}" -> SUMMARY (fallback: "summary unavailable")
1417
+ $ slack.post channel=${CHANNEL} text=${SUMMARY} (fallback: "post failed silently") -> ACK
1418
+ ```
1419
+
1420
+ Same pattern as the `# Requires:` cascade's `(fallback: ...)` syntax — consistent across compile-time (`# Requires:`) and runtime (`$` dispatch).
1421
+
1422
+ **Fallback value parsing.** Permissive: bare identifiers, quoted strings, and bracketed array literals all accepted. Matches the `# Requires:` cascade convention.
1423
+
1424
+ ```
1425
+ $ memory mode=fts query="..." -> RESULTS (fallback: []) # array literal
1426
+ $ llm prompt="..." -> VERDICT (fallback: unknown) # bare identifier
1427
+ $ slack.post text="..." -> ACK (fallback: "post failed") # quoted string
1428
+ ```
1429
+
1430
+ **Coerce-on-bind semantics.** On op throw or empty-result, the fallback value is bound to the outputVar via the same path as a successful result. Downstream targets see the fallback transparently — they don't need conditional checks to detect "did this op fail?" The op-level fallback IS the default-on-failure value.
1431
+
1432
+ ## Error propagation rules
1433
+
1434
+ - Op error → caught by `else:` if present, otherwise propagates to target
1435
+ - Target error → caught by `# OnError:` if present, otherwise propagates to caller
1436
+ - Caller can still catch via standard exception handling on compile / runtime invocation APIs
1437
+ - `else:` blocks are not allowed to declare their own error handlers
1438
+ - If an `else:` block itself fails, the whole target fails through `# OnError:` (if present)
1439
+
1440
+ ## Visibility into errors
1441
+
1442
+ Open spec question: should `${ERROR}` be ambient inside `else:` blocks (same shape as `${ERROR_CONTEXT}` in `# OnError:` fallbacks)? Current lean: yes. Useful for telemetry skills that need to know what failed before falling back. Not yet specified or shipped.
1443
+
1444
+ ## The fallback pattern is consistent across scopes
1445
+
1446
+ Same idea at every scope:
1447
+ - Compile-time: `# Requires: ... (fallback: value)`
1448
+ - Runtime op: `$ dispatch ... (fallback: value)`
1449
+ - Runtime target: `else:` block
1450
+ - Whole skill: `# OnError:` header
1451
+
1452
+ Authors composing complex skills use these in combination — op-level for transient errors, target-level for cohesive error paths, skill-level for last-resort degradation.
1453
+
1454
+ ## Connection to runtime observability
1455
+
1456
+ 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.
1457
+
1458
+ ## Composition — skills calling skills
1459
+
1460
+ 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.
1461
+
1462
+ Composition is exposed as a runtime-intrinsic op: `execute_skill(skill_name="...", inputs={...}) -> R`. Symmetric with `compile_skill` and `lint_skill` — same surface, same naming convention, no external-namespace dependency.
1463
+
1464
+ ## Surface
1465
+
1466
+ ```
1467
+ parent:
1468
+ execute_skill(skill_name="child") -> RESULT
1469
+ emit(text="Child returned: ${RESULT}")
1470
+ ```
1471
+
1472
+ The runtime resolves `skill_name` against the configured SkillStore at dispatch time, runs the child to completion against the runtime's wired connectors, and binds the result.
1473
+
1474
+ ## Tool signature
1475
+
1476
+ ```
1477
+ execute_skill({
1478
+ skill_name: string, // required — resolves via SkillStore
1479
+ inputs?: Record<string,string>, // optional — Vars override map
1480
+ mechanical?: boolean // optional — dry-run mode (default false)
1481
+ })
1482
+ ```
1483
+
1484
+ **Returns:** `{ final_vars, transcript, outputs, errors, target_order, provenance }`.
1485
+
1486
+ ## Semantics
1487
+
1488
+ **Skill resolution.** Missing skills produce a clean structured error (`MissingSkillReferenceError extends OpError`) — the parent's `(fallback: ...)` discipline applies if specified, otherwise the parent skill's `# OnError:` fallback fires if declared, otherwise the parent fails with the error propagated through.
1489
+
1490
+ **Input override.** `inputs` map keys must match the child's `# Vars:` declarations. Undeclared keys are ignored. Required vars without defaults must be supplied or dispatch fails before the child starts.
1491
+
1492
+ **Variable threading.** The parent's variable scope is sealed from the child's; the child sees only its declared `# Vars:` plus the inputs override plus ambient refs. The child's emitted result binds to the parent's named variable via `-> RESULT`. The child's transcript surfaces through the parent's transcript with provenance attribution.
1493
+
1494
+ **Mechanical mode (the TestFlight property).** When `mechanical: true`, the dispatch graph renders without firing side-effect ops. `$` dispatch ops bind null; runtime-intrinsic side-effect ops bind self-describing placeholder strings. The mechanical flag propagates through recursive `execute_skill` calls — the whole sub-graph previews end-to-end, no real services touched. Authors use this to validate a multi-skill composition chain before committing to any real call.
1495
+
1496
+ **Recursion guard.** The runtime enforces a configurable recursion-depth limit (default 10) to prevent infinite-loop composition. Exceeding the limit raises a clean structured error attributable to the offending dispatch site, not a stack overflow.
1497
+
1498
+ ## Forward-reference resolution
1499
+
1500
+ Skill references (`inline(skill=...)`, `execute_skill(skill_name=...)`) are validated at compile time but allow forward references with tier-2 advisories — making it possible to author sibling skills together (chicken-and-egg).
1501
+
1502
+ **Lint behavior:**
1503
+ - `unknown-skill-reference` (tier-2) — covers `inline()` and `execute_skill()` with missing targets
1504
+ - `deferred-skill-reference` (tier-3 advisory) — teaching message: *"Skill 'X' referenced via `<op>` is not currently in the SkillStore. Will resolve at execute time if the skill exists by then, or throw `SkillNotFoundError` if not. If this is a typo, fix it now; if it's a forward reference, this advisory will clear once you store 'X'."*
1505
+
1506
+ **Runtime behavior:** when a deferred reference still can't resolve at execute time, the runtime throws `MissingSkillReferenceError extends OpError` with structured fields (`missingSkillName`, `viaOp` for the op kind, inherited `target` and `opKind`). The error flows through `# OnError:` fallback chain naturally.
1507
+
1508
+ **Stronger contracts kept tier-1:**
1509
+ - `# OnError: <missing>` — error-handler missing-at-runtime is the worst possible UX moment to discover a missing reference; explicit at compile is the right call.
1510
+ - `disabled-skill-reference` — pointing at a Disabled skill is a stronger contract than "missing yet to be authored"; explicit at compile.
1511
+
1512
+ ## When to use composition vs other primitives
1513
+
1514
+ Three distinct cases that look similar but have different intents:
1515
+
1516
+ 1. **Get a value back from another skill.** Use `execute_skill(skill_name="...") -> RESULT` and use `${RESULT}` locally. This is the composition primitive case.
1517
+
1518
+ 2. **Delegate work to an agent as a task.** Use `# Output: template: <agent>` to route a compiled artifact through AgentConnector. The receiving agent acts on the prompt. *This is the Template-skill story* — uses compile-as-delivery, not execute-and-bind.
1519
+
1520
+ 3. **Augment an agent's context with a result.** Use `# Output: agent: <name>` to route the executed skill's output into the receiving agent's prompt context as augment-kind payload. *This is the Augmenting-skill story.*
1521
+
1522
+ The composition primitive (case 1) is for *intra-skill value passing*. Cases 2 and 3 are for *cross-agent delivery*. The runtime handles all three; the right primitive matches the intent.
1523
+
1524
+ ## Examples
1525
+
1526
+ **Simple call + bind:**
1527
+
1528
+ ```
1529
+ # Skill: greeting
1530
+ # Status: Approved
1531
+ # Vars: NAME=world
1532
+
1533
+ greet:
1534
+ emit(text="Hello, ${NAME}!")
1535
+
1536
+ default: greet
1537
+ ```
1538
+
1539
+ ```
1540
+ # Skill: parent
1541
+ # Status: Approved
1542
+
1543
+ call_greeting:
1544
+ execute_skill(skill_name="greeting") -> GREETING_RESULT
1545
+ emit(text="Greeting skill said: ${GREETING_RESULT}")
1546
+
1547
+ default: call_greeting
1548
+ ```
1549
+
1550
+ **Composition with input override:**
1551
+
1552
+ ```
1553
+ # Skill: parent-with-inputs
1554
+ # Status: Approved
1555
+ # Vars: TARGET_NAME=alice
1556
+
1557
+ call_with_inputs:
1558
+ execute_skill(skill_name="greeting", inputs={"NAME": "${TARGET_NAME}"}) -> RESULT
1559
+ emit(text="Customized greeting: ${RESULT}")
1560
+
1561
+ default: call_with_inputs
1562
+ ```
1563
+
1564
+ **Defensive composition with fallback:**
1565
+
1566
+ ```
1567
+ # Skill: defensive-parent
1568
+ # Status: Approved
1569
+
1570
+ call_maybe_missing:
1571
+ execute_skill(skill_name="might-not-exist") -> RESULT (fallback: "child unavailable")
1572
+ emit(text="Result: ${RESULT}")
1573
+
1574
+ default: call_maybe_missing
1575
+ ```
1576
+
1577
+ **TestFlight preview (from the runtime caller, not from inside a skill):**
1578
+
1579
+ ```
1580
+ execute_skill({
1581
+ skill_name: "parent",
1582
+ mechanical: true
1583
+ })
1584
+ ```
1585
+
1586
+ Renders the full dispatch chain — parent's targets in topo order, plus the child's targets where `execute_skill` would fire — without any real ops running. Useful for validating composition before commitment.
1587
+
1588
+ ## Compile-time inline composition: `inline()`
1589
+
1590
+ For *data skills* (skills marked `# Type: data`), the compile-time inline primitive `inline(skill="<name>")` resolves the data skill at compile time and bakes its emitted text into the parent's compiled artifact. The data skill's `content_hash` is recorded in the host's provenance; `skillfile audit` detects stale recompiles when a referenced data skill changes.
1591
+
1592
+ `inline()` is compile-time; `execute_skill()` is runtime. Different mechanisms, different use cases.
1593
+
1594
+ ## Authoring discipline
1595
+
1596
+ - 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.
1597
+ - Pair composition with `(fallback: ...)` when the child skill might fail and the parent has a sensible degraded path.
1598
+ - Use mechanical mode to TestFlight any multi-skill chain before shipping it as a Headless skill on a cron trigger.
1599
+ - Forward references work — author sibling skills in any order, validate independently. The tier-2 warning surfaces the deferred-resolution path; runtime catches genuine misses.
1600
+ - 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.
1601
+
1602
+ ## Static vs Dynamic — skill execution model
1603
+
1604
+ Orthogonal to the three skill categories (Headless / Augmenting / Template, which describe the skill's relationship to the frontier agent), every skill has an *execution model* that describes its relationship to the Skillscript runtime.
1605
+
1606
+ ## Static skill
1607
+
1608
+ A static skill compiles to a portable artifact that any agent capable of reading prose can execute. The compiled output is the deliverable — it does not require the Skillscript runtime, wired connectors, or dispatch machinery to run.
1609
+
1610
+ A static skill can be:
1611
+ - **A pure recipe** — procedure steps the executor follows using their own tools and judgment
1612
+ - **A data + recipe bundle** — data embedded in the skill (via `# Vars:` defaults or `inline(skill=...)` data-skills) plus instructions for what to do with it
1613
+ - **A reference to known-local tools** — may reference shell binaries (`curl`, `jq`, etc.) that the executor is expected to have; the executor invokes those themselves rather than via Skillscript's `shell()` dispatch
1614
+
1615
+ Static skills are useful for:
1616
+ - **Skill sharing** — a `.skill` artifact can be emailed, posted, or otherwise distributed without runtime ownership transfer
1617
+ - **Pipelining data with procedure** — "here are 30 customer reviews. Theme them and emit a summary." The data + recipe ship together; the executor runs them.
1618
+ - **Knowledge artifacts** — durable procedures that survive the runtime they were authored on
1619
+ - **Cross-platform deliveries** — a static skill compiled on a Skillscript runtime can be executed by Claude, GPT, or any frontier agent
1620
+
1621
+ The Template-kind skill is the canonical static shape — its `# Output: template:` declaration explicitly indicates the runtime doesn't dispatch the body; instead, the compiled artifact is routed to the receiving agent for execution.
1622
+
1623
+ ## Dynamic skill
1624
+
1625
+ A dynamic skill requires the Skillscript runtime to execute. The runtime walks the dispatch DAG, fires `$` ops against wired connectors, runs runtime-intrinsic ops (`emit`, `notify`, `ask`, `shell`, `file_read`, `file_write`, `execute_skill`), and threads outputs through variable bindings.
1626
+
1627
+ Dynamic skills are the default for:
1628
+ - **Autonomous workflows** — cron-fired Headless skills that fetch, reason, and emit
1629
+ - **Composition orchestrators** — parent skills that invoke child skills via `execute_skill()`
1630
+ - **Augmenting deliveries** — skills that gather material via dispatches before composing an augment payload
1631
+
1632
+ Dynamic skills bind their behavior to the specific runtime they're executed on: connector configuration, model selection, shell-execution mode, persistent trigger registry. They are not portable in the way static skills are.
1633
+
1634
+ ## Orthogonality to skill category
1635
+
1636
+ | | Headless | Augmenting | Template |
1637
+ |---|---|---|---|
1638
+ | **Static** | rare (only-`emit()` cron-fired emission skills) | possible (text-only augment with no fetches) | common (the default Template shape) |
1639
+ | **Dynamic** | common (the default Headless shape) | common (the default Augmenting shape) | possible (Template with `$` setup ops before the prompt body) |
1640
+
1641
+ The axes are independent. A skill author can produce any combination.
1642
+
1643
+ ## Compile-time portability validation (planned)
1644
+
1645
+ A `# Portability: static | dynamic` frontmatter header would declare the skill's intended execution model. The compiler would lint-check that the skill's op set is consistent with the declaration:
1646
+
1647
+ - `# Portability: static` → no `$` dispatch ops permitted; no side-effect runtime-intrinsics (`shell`, `file_write`, `ask`, `notify`, `execute_skill`); only the static-safe set (`emit`, `$set`, `$append`, `inline()`, conditionals, iteration)
1648
+ - `# Portability: dynamic` (or unset, the default) → any op permitted
1649
+
1650
+ A new compile mode `compile_skill({source, mode: "static"})` would render only the portable artifact, refusing skills that depend on runtime dispatch.
1651
+
1652
+ Pending implementation. See "Not yet implemented, but planned" at top.
1653
+
1654
+ ## When to choose which
1655
+
1656
+ **Choose static when:**
1657
+ - The skill should be portable beyond this runtime
1658
+ - The skill's value is the procedure or data + procedure, not the dispatch behavior
1659
+ - The skill will be shared, distributed, or executed by an external agent
1660
+ - Pipelining a known data payload through a recipe
1661
+
1662
+ **Choose dynamic when:**
1663
+ - The skill needs to fetch, reason against, or emit through wired connectors
1664
+ - The skill is autonomous (cron-fired) or augmenting (live context)
1665
+ - The skill composes other skills via runtime dispatch (`execute_skill()`)
1666
+ - The skill is bound to this runtime's connector configuration
1667
+
1668
+ ## Implementation status
1669
+
1670
+ Today's skills are all "dynamic" by default; static skills work in practice (any skill whose ops are only `emit()` / `$set` / `$append` / `inline()` / conditionals / iteration is portable), but the language doesn't yet declare or enforce the distinction.
1671
+
1672
+ The recipe-with-data pattern is implicit today via `# Vars:` defaults + `inline(skill=...)` data-skill inlines — a static skill can carry payload via these mechanisms without runtime dependence.
1673
+
1674
+ ## Tests — # Tests: block, given/expect assertions
1675
+
1676
+ The `# Tests:` header introduces a block of test cases that travel with the skill body. Each case has `given:` (variable overrides) and `expect:` (assertions on the compiled output or runtime side effects).
1677
+
1678
+ ## Status
1679
+
1680
+ Header parsing and test runner not yet shipped. See "Not yet implemented, but planned" at top. The grammar below is the design but implementation is pending.
1681
+
1682
+ ## Proposed grammar
1683
+
1684
+ ```
1685
+ # Tests:
1686
+ - name: "basic_url_filter"
1687
+ given:
1688
+ LOCATION: "Asheville, NC"
1689
+ expect:
1690
+ compiled_output_contains: "wttr.in/Asheville%2C%20NC"
1691
+
1692
+ - name: "missing_required_var_errors"
1693
+ given:
1694
+ LOCATION: null
1695
+ expect:
1696
+ compile_error: "Missing required variable: LOCATION"
1697
+
1698
+ - name: "fetch_failure_runs_else_block"
1699
+ given:
1700
+ TOPIC: "definitely-not-a-real-topic-xyz"
1701
+ expect:
1702
+ target_else_executed: "fetch"
1703
+ result_value: ""
1704
+ ```
1705
+
1706
+ ## Execution
1707
+
1708
+ Run via the compile API with `format: "test"` (and optional `test_case: "<name>"` to run a single case). All cases run when `test_case` is omitted. Returns pass/fail per assertion with diagnostic detail.
1709
+
1710
+ Normal `prompt` / `prose` compilation ignores the `# Tests:` section entirely — tests travel with the skill without affecting production use.
1711
+
1712
+ ## Assertion types
1713
+
1714
+ ### Compile-time assertions
1715
+
1716
+ - `compiled_output_contains: "<substring>"` — the rendered prompt artifact contains the given substring
1717
+ - `compile_error: "<substring>"` — compilation fails with an error message containing the substring
1718
+ - `compiled_output_does_not_contain: "<substring>"` — negative assertion
1719
+
1720
+ ### Runtime assertions (for `format: "test"` execution)
1721
+
1722
+ - `target_else_executed: "<target_name>"` — verifies the `else:` branch ran
1723
+ - `onerror_invoked: "<fallback_skill>"` — verifies the `# OnError:` skill was called
1724
+ - `op_fallback_used: "<target.op_index>"` — verifies an op-level fallback value was substituted
1725
+ - `result_value: "<expected_string>"` — the skill's final output value
1726
+
1727
+ ## Open spec questions
1728
+
1729
+ ### Runtime assertion sandboxing
1730
+
1731
+ `# Tests:` cases that exercise runtime behavior (memory writes, shell ops, LocalModel calls) need a sandbox so they don't pollute production data. Two approaches:
1732
+ - Scratch DB / scratch connector overrides for tests
1733
+ - Skip-and-warn for non-deterministic ops, only assert deterministic compile-time properties
1734
+
1735
+ Deferred until the test runner ships.
1736
+
1737
+ ### Property-based tests
1738
+
1739
+ The current design covers example-based tests. Property-based tests (`for all inputs in {...}, output matches pattern X`) would be a useful future addition but require a generator framework.
1740
+
1741
+ ## Connection to authoring discipline
1742
+
1743
+ The authoring loop — *author → lint → revise → store* — depends on tests-as-preflight being cheap to author and cheap to run. The `# Tests:` block makes this possible at skill-source-level; the lint pass enforces structural correctness; together they raise the bar for what enters the library.
1744
+
1745
+ Skill discovery via `skill_list()` (see SkillStore docs) closes the related visibility gap — `execute_skill()` references can be validated against the discoverable surface at compile time.
1746
+
1747
+ ## Future grammar extensions — sensors, time primitives, suppression, persistent state, capability declarations, debounce
1748
+
1749
+ Design rationale for planned features. The user-facing list of "what's coming" lives in the "Not yet implemented, but planned" section at top; this section documents the *why* behind each planned addition. When the language extends, the relevant grammar moves into its canonical section (Ops reference, Variables, Triggers, etc.) and the design-rationale entry here is replaced with a cross-reference.
1750
+
1751
+ ## Sensors as a language category
1752
+
1753
+ Currently `# Triggers:` includes `sensor:` as a trigger source. The planned redesign splits sensors into their own category:
1754
+
1755
+ ```
1756
+ # Sensors: presence, screen-state, voice-prosody
1757
+ # Triggers: cron: 0 8 * * *
1758
+ ```
1759
+
1760
+ **Distinction:** Sensors are continuous channels the agent reads but doesn't emit on. Triggers are discrete events that fire the skill. Conflating them in one header produces a worse language for both — sensors need different semantics (continuous read, accessible via ambient refs, privacy-gated) than triggers (discrete fire, dispatch semantics).
1761
+
1762
+ Pending: ambient refs for sensor values (`${SENSOR.presence}`, `${SENSOR.voice-prosody.affect}`) and the privacy-gating discipline that determines when a sensor is readable.
1763
+
1764
+ ## Time as first-class primitives
1765
+
1766
+ Current ambient time: `${NOW}` (wall-clock ISO timestamp). Planned relative-time primitives:
1767
+
1768
+ ```
1769
+ ${SECONDS_SINCE_LAST_USER_MESSAGE}
1770
+ ${MINUTES_SINCE_SESSION_START}
1771
+ ${SECONDS_SINCE_LAST_FIRE_OF.<skill-name>}
1772
+ ```
1773
+
1774
+ **Rationale:** Most "right time" reasoning is relative, not wall-clock. Authoring relative-time guards requires either runtime-state tracking (which authors then rebuild manually) or first-class primitives. The latter wins.
1775
+
1776
+ ## Absence as trigger
1777
+
1778
+ Different shape from event triggers — "fire if user hasn't messaged in N minutes" is a wait-for-nothing primitive, not a wait-for-event primitive. Proposed grammar:
1779
+
1780
+ ```
1781
+ # Triggers: idle: 5m
1782
+ ```
1783
+
1784
+ Runtime tracks the relevant idleness counter and fires when the threshold crosses. Separate dispatch mechanism from event triggers.
1785
+
1786
+ ## Time-windowed aggregation
1787
+
1788
+ Filter-like primitives that operate on state across firings:
1789
+
1790
+ ```
1791
+ $ llm prompt="..." -> VERDICT
1792
+ # pseudo-syntax pending: aggregate over a window
1793
+ ${VERDICT|last-5|count-where:value=="frustrated"}
1794
+ ```
1795
+
1796
+ **Rationale:** "User has shown frustration in 3 of 5 recent turns" is a canonical sensor-derived condition. Without first-class windowing, every skill rebuilds ring buffers. Pending design: filter syntax vs new op kind.
1797
+
1798
+ ## Backpressure / debouncing
1799
+
1800
+ Sensors produce floods. First-class primitives for rate limiting:
1801
+
1802
+ ```
1803
+ # Debounce: 5s
1804
+ # RateLimit: 1/minute
1805
+ # Coalesce: latest
1806
+ ```
1807
+
1808
+ Headers declare the runtime's queueing policy. Runtime enforces; skill body doesn't reimplement.
1809
+
1810
+ ## Suppression as valid output
1811
+
1812
+ Current behavior: a skill that fires must produce *some* output (even empty string). Pending: explicit "fire-and-suppress" — the skill considered the situation and decided not to emit. Different from `# Output: none` (which signals "I do side effects only").
1813
+
1814
+ Proposed: an explicit suppression op or `$set OUTPUT = null` triggers suppression-detection in the runtime. Output routers skip delivery; trigger fire counts increment for telemetry; no consumer surface receives noise.
1815
+
1816
+ **Rationale:** Without suppression, signal pipelines become noisy. "Fire everything, hope the right one wins" turns the inbox-to-context into spam. Discipline that makes pub-sub tractable.
1817
+
1818
+ ## Persistent state with declared scope
1819
+
1820
+ Current `$set` is per-execution; no lifecycle beyond the fire. Pending:
1821
+
1822
+ ```
1823
+ $set NAME = value scope=skill-local
1824
+ $set NAME = value scope=agent-global
1825
+ $set NAME = value scope=session
1826
+ ```
1827
+
1828
+ **Scopes:**
1829
+ - `skill-local` — persists across fires of this skill, not visible to other skills
1830
+ - `agent-global` — visible to all skills of the same agent
1831
+ - `session` — alive for the duration of the current session, cleared at session end
1832
+
1833
+ Backed by a configured data-records connector (the same surface `# Requires:` reads from) with conventionally-namespaced keys (e.g., `state:skill-local:<skill-name>:<key>`).
1834
+
1835
+ **Rationale:** Most interesting skills need memory across firings — change-detection, windowing, dedup-against-recent. Without lifecycle, every skill rebuilds state tracking via raw memory-write / memory-query calls.
1836
+
1837
+ ## Cross-skill pub-sub
1838
+
1839
+ Procedural `execute_skill()` invocation handles one-to-one composition. Pub-sub handles many-to-many.
1840
+
1841
+ ```
1842
+ # Publishes: signal.frustration-detected
1843
+ # Subscribes: signal.user-confused
1844
+ ```
1845
+
1846
+ When a skill publishes a signal, all subscribed skills fire (independent executions, parallel dispatch). Decouples emitters from consumers — the inverse of direct invocation.
1847
+
1848
+ **Rationale:** When signal flow is many-to-many, direct invocation couples everything to everything. Pub-sub keeps emitters ignorant of consumers.
1849
+
1850
+ ## Confidence/threshold gating
1851
+
1852
+ Declarative guards on skill firing:
1853
+
1854
+ ```
1855
+ # RequiresConfidence: classifier >= 0.8
1856
+ # RequiresThreshold: change-delta >= 0.3
1857
+ ```
1858
+
1859
+ Runtime evaluates the guard before dispatching the skill body. Lets sensitive skills opt out of low-confidence triggers without each skill's body rebuilding the same guard expression.
1860
+
1861
+ ## Invocation-control axis
1862
+
1863
+ Currently a skill is uniformly invocable from any caller (user via explicit command, agent mid-conversation, trigger autonomous fire). Some skills are user-only intents (user types a slash-command to invoke), some are agent-only behaviors (agent picks the skill via description-match while reasoning), some are trigger-only autonomous fires.
1864
+
1865
+ Proposed grammar:
1866
+
1867
+ ```
1868
+ # Invocable-By: user, agent, trigger
1869
+ # Invocable-By: trigger # autonomous only
1870
+ # Invocable-By: user # explicit-command only; agent can't pick it via reasoning
1871
+ ```
1872
+
1873
+ Header is a permissive list; absent means all three (current default behavior). Lint flags semantically-inconsistent declarations — a skill with `# Triggers: cron:` but `# Invocable-By: user` is a contradiction the rule catches.
1874
+
1875
+ **Rationale:** without the axis, sensitive operations (destructive writes, external messages, irreversible state changes) leak across invocation boundaries. An agent reading skill descriptions might invoke a skill that should only fire on explicit user command. Capability declarations enforce more granularly, but the user/agent/trigger triad is the structural distinction that catches most surface-leak bugs cheaply.
1876
+
1877
+ ## Channel/locality awareness
1878
+
1879
+ Ambient refs for current channel state:
1880
+
1881
+ ```
1882
+ ${CHANNEL_TYPE} # slack-dm, slack-channel, voice, web, etc.
1883
+ ${CHANNEL_PRIVACY} # private, public, group
1884
+ ${CHANNEL_NAME}
1885
+ ```
1886
+
1887
+ Privacy gating uses these. A sensor-fired skill that reads `voice-prosody` should not emit to a public channel. Runtime enforces; ambient refs let skill bodies make routing decisions.
1888
+
1889
+ **This is the structural gate** that makes the sensor direction socially defensible — privacy as precondition, not feature.
1890
+
1891
+ ## Introspection primitives
1892
+
1893
+ Self-state queries:
1894
+
1895
+ ```
1896
+ ${PROMPT_CONTEXT.size}
1897
+ ${SKILLS_FIRED_RECENTLY.last-1h}
1898
+ ${SELF.confidence-trend}
1899
+ ```
1900
+
1901
+ **Rationale:** Skills can't reason about other skills' state today. Introspection closes the gap.
1902
+
1903
+ ## Capability declarations
1904
+
1905
+ Skill declares its required surfaces:
1906
+
1907
+ ```
1908
+ # Requires-Capabilities: sensors=[mic, camera], tools=[memorystore.write, slack.post]
1909
+ # Requires-Privacy: private-channel-only
1910
+ ```
1911
+
1912
+ Runtime fails-fast on missing capabilities. Trust precondition for sensor work — operators can audit which skills touch which surfaces.
1913
+
1914
+ ## Build order rationale
1915
+
1916
+ Some features depend on others:
1917
+ - Suppression + persistent state should land before sensors (sensor work would compound problems without them)
1918
+ - Pub-sub needs sensors producing traffic before it has anything to route
1919
+ - Introspection is ergonomic, not foundational — useful but skippable
1920
+ - Capability declarations are the trust gate that makes sensor + privacy work socially defensible
1921
+
1922
+ The "Not yet implemented, but planned" section at top tracks the user-facing surface; this section preserves the design-order argument for when implementation work picks up.
1923
+
1924
+ ## Open spec questions — unresolved language design decisions
1925
+
1926
+ Questions surfaced during design that haven't been resolved. Each carries a current lean where applicable. Resolved items have moved to their canonical sections; this section tracks only what's still open.
1927
+
1928
+ ## 1. Block execution model — write down the rules
1929
+
1930
+ Within a target body, op ordering and variable binding conventions aren't fully written down. Specific questions:
1931
+ - Can `emit()` calls precede `$` ops in the same target? (Yes; `emit()` has no dependency on subsequent ops.)
1932
+ - What's the default output binding when `-> NAME` is omitted? (`${target.output}` — same as bare `target` referenced from other blocks.)
1933
+ - How do cross-block references work syntactically? (`${other_target.output}` or `${VAR_BOUND_THERE}`.)
1934
+
1935
+ **Write a "Block execution model" subsection** in the Overview or Ops reference section. No semantic change, just documentation gap.
1936
+
1937
+ ## 2. `else:` block visibility into the error
1938
+
1939
+ Should `${ERROR}` be an ambient ref inside `else:` blocks, populated with the error type/message? Lean: yes, same shape as `${ERROR_CONTEXT}` in `# OnError:` fallback skills. Useful for logging/telemetry skills.
1940
+
1941
+ ## 3. Multiple triggers — concurrency
1942
+
1943
+ If `cron: 0 8 * * *` and `event: user.present` both fire within seconds, does the skill run twice (independent) or get deduped? Lean: independent. Author dedups via state if needed. Affects dispatch layer.
1944
+
1945
+ ## 4. `execute_skill()` invocation vs trigger firing
1946
+
1947
+ When skill A invokes skill B via `execute_skill()`, do skill B's `# Triggers:` fire? Almost certainly no — `execute_skill()` is direct invocation, distinct from the trigger event surface. Worth saying explicitly.
1948
+
1949
+ ## 5. File-watch path semantics
1950
+
1951
+ Recursive or directory-only by default? Inotify supports both. Lean: directory-only default; offer recursive via `file-watch-recursive:` or `file-watch: <path> (recursive)`. Affects dispatch layer when the file-watch trigger source actually fires (currently parse-only).
1952
+
1953
+ ## 6. Output target delivery failures
1954
+
1955
+ If a delivery target is unreachable when the skill fires, what happens? Lean: delivery failure is its own retryable error; queue if possible, else error to caller. Worth a separate small spec section. Affects dispatch layer.
1956
+
1957
+ ## 7. Skill versioning rollback UX
1958
+
1959
+ Edits via upsert preserve history through substrate versioning (SqliteSkillStore's `skill_versions` table, FilesystemSkillStore's git history), but no first-class "rollback" affordance. Probably needs a `--version <N>` flag on the compile API or a sister tool.
1960
+
1961
+ ## 8. Connector capability declarations
1962
+
1963
+ Skills can declare required connector capabilities via `# Requires:` for var resolution. Extending this to "needs semantic search" / "needs structured-extraction model with 32K context" capabilities would be useful for the substrate-portable story. Pending design.
1964
+
1965
+ ## 9. Per-op timeouts
1966
+
1967
+ Hung dispatches hang the skill without explicit timeout configuration. Lean: skill-level `# Timeout:` header + per-op `timeoutSeconds=N` kwarg + runtime defaults. Pending implementation; see "Not yet implemented, but planned" at top.
1968
+
1969
+ ---
1970
+
1971
+ *Rendered from `skillscript/skillscript-language-reference` — 2026-05-29 10:20 EDT*
1972
+ *Source of truth: AMP (`amp_render_document("skillscript/skillscript-language-reference")`)*