Skip to main content
How to wire skillscript-runtime into your deployment. Written for Joe-Programmer: you have your own substrate stack (data store, agent harness, LLM endpoint, filesystem), and you want skillscript to slot in rather than dictate. This playbook covers the load-bearing decisions, the two wiring patterns, and the conventions that keep your local modifications upstream-merge-friendly.

The four substrates skillscript expects

Skillscript-runtime is substrate-neutral and assumes you have (or will choose):
  1. A filesystem — for skill source files (.skill.md), trace records, the bundled sqlite databases. Sandbox via container, chroot, or limited-privilege process — operator’s call.
  2. A data store — for retrieval and writes from skill ops. Could be SQLite-FTS (bundled), a vector database, an in-house store, an Obsidian-style notes system, a memory broker — whatever you already have.
  3. An LLM endpoint — Ollama running locally (bundled), a hosted API like OpenAI / Anthropic / Azure, or your own inference server.
  4. An agent harness — where skill output is delivered. Could be tmux sessions, a webhook receiver, an in-house agent runtime, or no harness at all (skills run for their text output only).
Each of these maps to a typed connector contract: SkillStore, DataStore, LocalModel, AgentConnector. Plus McpConnector for any external tool you want to invoke from a skill body.

What the runtime promises connector implementors

You only need to know what methods the runtime will call. Everything else — where data lives, what fields you honor, internal authorization, expiration, indexing — is your implementation choice. The contracts:
DataStore — substrate-neutral data persistence
  query(filters)    runtime asks: "find records matching these filters"
  write(record)     runtime asks: "store this; return id + timestamp"
  get(id)           runtime asks: "give me this specific record"

SkillStore — substrate-neutral skill source persistence
  load(name)             "give me this skill's source"
  store(name, source)    "write/version this skill"
  query(filter)          "list matching skills"
  update_status()        "Draft → Approved → Disabled transition"
  versions(name)         "audit trail"
  metadata(name)         "header info without body"
  delete(name)           "remove all versions"

LocalModel — substrate-neutral LLM dispatch
  run(prompt, opts)      "complete this prompt; return text"

AgentConnector — substrate-neutral agent delivery
  list_agents()          "what agent ids do you handle?"
  deliver(id, payload)   "send this to that agent"
  wake(id, opts)         "rouse that agent"
  health_check()         "are you reachable?"
  request_response(...)  "deliver + collect a reply"

McpConnector — external tool dispatch (substrate-neutral wire)
  call(toolName, args, ctx?)   "invoke this tool with these kwargs"
What’s NOT in the contracts (and is your concern as implementor):
  • Where the data lives (sqlite / your DB / hosted service / vector store)
  • What metadata fields your substrate honors or ignores (kwargs passed via metadata.<key> ride through; you choose what to do with them)
  • Vaults, namespaces, tenants, access-control — substrate-specific
  • Expiration / decay / pinning / reranking — substrate-specific
  • Authentication into your own backend — your code, your decision

Case 1 vs Case 2 — the load-bearing wiring decision

This is the most important architectural choice you’ll make.

Case 1 — typed-contract wiring (substrate-portable)

You implement the typed connector contracts (DataStore, LocalModel, etc.) against your substrate. The bridge classes (DataStoreMcpConnector, LocalModelMcpConnector) surface them as canonical $ data_read / $ llm dispatch.
class MyDataStore implements DataStore {
  async query(filters: QueryFilters): Promise<PortableData[]> { /* ... */ }
  async write(record: DataWrite): Promise<DataWriteRecord> { /* ... */ }
  async get(id: string): Promise<PortableData | null> { /* ... */ }
  async manifest(): Promise<ManifestInfo> { /* ... */ }
}

registry.registerDataStore("primary", new MyDataStore());
In skills:
$ data_read mode=fts query="customer feedback" limit=10 -> CONTEXT
This same skill body runs unchanged against your substrate, against SQLite-FTS (bundled), against Pinecone, against any substrate that conforms to the typed contract. Skills are portable. Substrate-specific concerns (where the records live, what metadata you honor) stay inside your impl.

Case 2 — MCP-tools wiring (substrate-locked)

Your substrate exposes itself as MCP tools (via a local MCP server or remote one). You wire it as an McpConnector and skills reference its tools by name with substrate-specific kwargs. MCP transport — two paths. The protocol’s wire layer is the same; the transport differs:
  • Stdio MCP (most common for community servers — YouTrack, GitHub, Linear, etc.): the MCP server is a binary you spawn as a child process and communicate with via stdin/stdout. Wired via RemoteMcpConnector:
    {
      "my_store": {
        "class": "RemoteMcpConnector",
        "config": {
          "command": "my-store-mcp-server",
          "args": ["--db", "/var/store"],
          "framing": "newline"
        }
      }
    }
    
    Set "framing": "newline" explicitly — mcp-remote and most spec-compliant MCP stdio servers use newline-delimited JSON. The legacy lsp default silently hangs to init_timeout against newline-framed servers. See configuration.md — RemoteMcpConnector config — stdio framing. The command (server binary, node/npx, uv/uvx, etc.) must be on the runtime host’s PATH at spawn time. A programmatic bootstrap inherits its launching shell’s PATH; the CLI inherits the user’s shell PATH at invocation.
  • HTTP MCP / Streamable HTTP (Anthropic’s hosted MCP, GitHub MCP, Linear MCP, etc.): the MCP server speaks JSON-RPC over HTTP with Server-Sent Events for the stream channel. Two ways to wire it:
    • (a) Stdio bridgeRemoteMcpConnector + npx mcp-remote https://... runs a node child process that bridges HTTP MCP into stdio for the runtime to consume. mcp-remote auto-negotiates the transport (Streamable HTTP first, falling back to SSE); add --sse only if the server requires SSE explicitly. Works today; adds the bridge subprocess overhead per call.
    • (b) Direct HTTP connector (bundled)HttpMcpConnector speaks Streamable HTTP MCP directly, no subprocess. Substrate-neutral: works against any MCP server speaking the spec. Wired declaratively:
      {
        "my_store": {
          "class": "HttpMcpConnector",
          "config": {
            "endpoint": "https://mcp.example.com/",
            "headers": {
              "Authorization": "Bearer ${API_TOKEN}"
            },
            "identityHeader": "X-Agent-Id",
            "maxPoolSize": 64
          }
        }
      }
      
      Identity-propagation config:
      • identityHeader — when set, the connector reads ctx.agentId per call and threads it as both a per-call request header AND the session-pinning key. Each distinct agent identity gets its own session, pinned to that identity at server-side initialize time. Required for substrates that pin sessions to the initializing identity (the common case for memory-substrate MCPs). Omit it when every caller shares one identity — all calls then share a single default session.
      • maxPoolSize — optional cap on the per-identity session pool (LRU eviction by access recency). Default unlimited; set when your substrate has session-count limits or you want bounded resource use.
      When identityHeader is set, supports_identity_propagation: true is declared in runtime_capabilities. The RuntimeCapabilitiesConformance suite then requires Level 1 + Level 2 probes wired via flagProbes — see Connector Contract Reference for the probe contract.
    • (c) Custom direct connector — fork examples/connectors/McpConnectorTemplate/ when you need behavior the bundled HttpMcpConnector doesn’t cover (e.g., a non-spec auth handshake, tool-name normalization, custom retry logic).
Pick (b) by default — no subprocess, no implementation effort, works against any compliant Streamable HTTP MCP server. Pick (a) only when the server is behind tooling that requires the stdio bridge. Pick (c) only when your substrate needs behavior the bundled connector doesn’t expose. In skills, regardless of transport:
$ my_store.search query="customer feedback" region="eu-west" cluster="prod" -> CONTEXT
This skill body is locked to my_store — its specific kwargs (region, cluster) and response shape. To move to a different substrate, every call site has to be rewritten. ⚠ Programmatic adopters: use bootstrapFromEnv() and it loads $SKILLSCRIPT_HOME/connectors.json (plus .env + SKILLSCRIPT_*) for you, exactly like the CLI. Only raw bootstrap() skips them — it reads connectors.json solely when you pass connectorsConfigPath, and skipping it makes $ name.tool calls fail at runtime with unknown-connector and no hint that the file simply wasn’t read. See §“Programmatic bootstrap path” below — bootstrapFromEnv() is the recommended default; the raw-bootstrap() explicit-wiring pattern is for hand-assembled custom substrates.

Picking — the tradeoff

AspectCase 1 (typed)Case 2 (MCP)
Skill portability✓ portable✗ substrate-locked
Substrate feature coverageLimited to typed contract surfaceFull substrate surface
Implementation effortImplement typed interfaceWire existing MCP server
Best forSkills you want to shipSubstrate-specific power features
The choice is per-skill, not per-substrate. You can wire both — register data_read (typed-contract via bridge) AND my_store (MCP) — and let skills opt into portability by which connector name they reference. For the substrate-portability claim to hold, the substrates you care about must be Case-1-wired.

Joe Programmer setup walkthrough

1. Install + initialize

npm install -g skillscript-runtime
skillfile init --here
This creates ~/.skillscript/ with skills/, traces/, an empty connectors.json, and a config.toml stub. If you’re writing a custom bootstrap (not just using the bundled CLI): install the package locally (npm install skillscript-runtime in your adopter project) so tsx/node can resolve it alongside your project dependencies. A global install is convenient for the bundled CLI but doesn’t expose the package to your custom bootstrap’s module resolution. The package is ESM-only. npm init -y produces a CJS package.json by default, which will fail your first bootstrap run with top-level-await / ESM-import errors. Switch your adopter project to ESM before authoring bootstrap code:
npm pkg set type=module

2. Decide on substrate wiring

For each of the four substrates (data store, LLM, agent harness, MCP tools), decide Case 1 or Case 2. The onboarding scaffold (examples/onboarding-scaffold/) is Case 1 end-to-end against a file-backed data store + OpenAI + tmux.

3. Configure runtime knobs

Create skillscript.config.json in your $SKILLSCRIPT_HOME:
{
  "skillsDir": "${SKILLSCRIPT_HOME}/skills",
  "traceDir": "${SKILLSCRIPT_HOME}/traces",
  "dataDbPath": "${SKILLSCRIPT_HOME}/data.db",
  "dashboard": { "port": 7878 }
}
${VAR} substitutes against process.env. See skillscript.config.json.example in the repo for the full surface.

4. Wire your substrates

For the bundled CLI path (no custom code): use connectors.json to declare your MCP servers; use OPENAI_API_KEY / OLLAMA_BASE_URL env vars; run skillfile dashboard --config ./skillscript.config.json. For custom substrates: write your own bootstrap. See examples/custom-bootstrap.example.ts and examples/onboarding-scaffold/bootstrap.ts for complete worked walkthroughs. Security knobs that adopters wiring real substrates should know about:
  • The approval boundary (secured mode). SKILLSCRIPT_SECURED_MODE=true enforces that only key-signed skills perform effects. Default-deny by design — leave it off only for trusted-author / single-operator setups. Full detail, the approve flow, and key custody: Approval + secured mode.
  • Filesystem path allowlist. SKILLSCRIPT_FS_ALLOWLIST is default-deny — file_read / file_write refuse every path until you wire roots. See Filesystem path allowlist. (Keep your approval-key directory out of it.)
  • Per-connector tool allowlistsallowed_tools on each connectors.json MCP connector entry restricts which tools that connector can dispatch. Three-state (undefined = allow all, [] = allow none, listed = exactly those). Tier-1 disallowed-tool lint + runtime defense-in-depth refuse out-of-list dispatch. allowed_tools belongs at the entry top-level, sibling to class and config — NOT inside the config block. The loader hard-errors on misplacement (placing it inside config: would silently allow-all every tool — the worst-case failure mode for a security control). See docs/configuration.md §“Named MCP connector instances” for the JSON shape.
  • Shell-execution disciplineshell(command="...") runs structured-spawn by default (binary on PATH, whitespace-tokenized argv, no bash). shell(command="...", unsafe=true) opts into bash interpretation (pipes, $VAR, command substitution) and refuses to fire unless the runtime is configured with enable_unsafe_shell = true in config.toml. Lint flags every unsafe=true op tier-2 to keep audit posture visible. See scaffold/config.toml for the documented default + help({topic:"lint-codes"}) for the unsafe-shell-disabled rule.
If you have a custom JSON-instantiable McpConnector class, register it with registerConnectorClass before loading config:
import { registerConnectorClass, loadConnectorsConfig } from "skillscript-runtime";
import { MyAdopterConnector } from "./my-adopter-connector.js";

registerConnectorClass("MyAdopterConnector", {
  ctor: MyAdopterConnector,
  fromConfig: (cfg) => new MyAdopterConnector(cfg),
});

const { connectors } = loadConnectorsConfig({ path: "./connectors.json" });

5. Two-instance posture

Running dev-skillscript alongside an adopter-wiring instance on the same machine:
# dev
skillfile dashboard

# adopter (different port + paths)
SKILLSCRIPT_HOME=/path/to/adopter skillfile dashboard --config /path/to/adopter/skillscript.config.json
Each instance reads its own config; ports/paths/db files don’t collide.

6. Running as a supervised service

For cron/event fires to be reliable, the dashboard/MCP host must run supervised — surviving both reboot and crash. The scheduler is in-process, at-most-once, with no catch-up (see Trigger model — durability honesty): a down process silently misses fires. A cron skill like 0 3 * * * only fires if the process happens to be up at 3am. nohup-from-a-shell isn’t sufficient (dies on reboot/logout/crash). Use the OS supervisor: a macOS LaunchAgent (RunAtLoad + KeepAlive) or a Linux systemd unit. macOS — ~/Library/LaunchAgents/com.skillscript.adopter.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.skillscript.adopter</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/node</string>
    <string>--import=tsx</string>
    <string>/path/to/adopter/bootstrap.ts</string>
  </array>
  <key>WorkingDirectory</key><string>/path/to/adopter</string>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>SKILLSCRIPT_HOME</key><string>/path/to/adopter</string>
  </dict>
  <key>StandardOutPath</key><string>/path/to/adopter/logs/stdout.log</string>
  <key>StandardErrorPath</key><string>/path/to/adopter/logs/stderr.log</string>
</dict>
</plist>
Load with launchctl load ~/Library/LaunchAgents/com.skillscript.adopter.plist. Linux — /etc/systemd/system/skillscript-adopter.service:
[Unit]
Description=Skillscript adopter runtime
After=network.target

[Service]
ExecStart=/usr/bin/node --import=tsx /path/to/adopter/bootstrap.ts
WorkingDirectory=/path/to/adopter
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=SKILLSCRIPT_HOME=/path/to/adopter
Restart=always
RestartSec=5
User=adopter

[Install]
WantedBy=multi-user.target
Enable with systemctl enable --now skillscript-adopter.service. The load-bearing gotcha — pin PATH in the supervisor environment. launchd and systemd run with a bare PATH (typically /usr/bin:/bin), not your shell’s. The runtime’s spawned binaries — node/npx for RemoteMcpConnector (mcp-remote bridges), uv/uvx for Python MCP servers, every binary on the shell allowlist (gh, curl, git, etc.) — must be on that pinned PATH. Omit it and connectors + shell ops fail with “command not found” under the supervisor even though they work from your shell. This is the same class of environment-specific break as forgetting to pin TMUX_TMPDIR in a tmux launcher’s plist. Spirit-of: same PATH concern as the Case-2 stdio host-PATH note — two surfaces (the shell that launches it ad-hoc AND the supervisor that keeps it up persistently). A few additional gotchas:
  • WorkingDirectory matters. Set it to the bootstrap’s directory so relative imports (./connectors-config.ts) and node_modules resolve correctly. Without it, the supervisor runs from / and ESM imports fail.
  • Single supervisor only. Don’t also run the dashboard manually (nohup skillfile dashboard &) alongside the supervised instance — two listeners on the same port either collide or round-robin events, dropping fires silently.
  • .env still loads. The runtime’s process.loadEnvFile call reads .env from the working directory at boot, independent of the supervisor’s environment block. Don’t duplicate secrets in both — keep them in .env and let the bootstrap load them.

7. Wire your agent to the runtime over MCP

With the service running, point your agent at the runtime’s /rpc endpoint. Simplest path (Claude Code + similar agent hosts): just ask. “Add the skillscript MCP server at http://localhost:7878/rpc” — Claude Code writes the config for you. Skip the JSON below unless you want to understand what landed or you’re on a host that doesn’t write its own config. Manual config — standard HTTP MCP (modern agent hosts that speak HTTP MCP directly) — drop the runtime in your agent’s MCP config (~/.claude.json, .mcp.json, claude_desktop_config.json, etc.):
{
  "mcpServers": {
    "skillscript": {
      "type": "http",
      "url": "http://localhost:7878/rpc"
    }
  }
}
Stdio bridge (older Claude Desktop builds + any client that doesn’t speak HTTP MCP) — bridge via mcp-remote the same way you’d bridge any HTTP MCP server. Pair this with the framing: "newline" note from Case 2 if you wire it the other direction (runtime → external HTTP MCP):
{
  "mcpServers": {
    "skillscript": {
      "command": "npx",
      "args": ["mcp-remote", "http://localhost:7878/rpc"]
    }
  }
}
Two operational notes:
  • Port matches what the runtime binds. Default 7878 (SKILLSCRIPT_PORT env var / dashboard.port config). Two-instance setups (dev + adopter side-by-side per §5) need distinct ports — e.g., adopter on 7879. Whatever the supervisor unit specifies is what the agent config must point at.
  • Auth + bind address. /rpc has no auth and binds to 127.0.0.1 by default — the right posture for a local agent talking to a local runtime. If you set SKILLSCRIPT_HOST=0.0.0.0 to reach the runtime across the network, front it with your own auth layer (reverse proxy + bearer token, mTLS, etc.) — same posture as the /event HTTP ingress, which has its own bearer-token gate (SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN).

Approval + secured mode

Every skill carries a lifecycle status — Draft → Approved → Disabled. Only Approved skills execute; Draft is the safety gate (a skill under authoring or review), Disabled is retired. That status gate applies in both runtime modes. What the mode decides is whether an Approved skill must additionally be cryptographically keyed:
UnsecuredSecured (SKILLSCRIPT_SECURED_MODE=true)
Draft skillrefusedrefused
Approved skillruns — a bare # Status: Approved is sufficient (unkeyed)runs only if the body carries a valid operator signature
Tamper-evidencenone (an Approved body can be edited freely)yes — editing the body breaks the signature
Effectful ops ($, shell, file_*, notify)runrefused unless the skill is signed (the effectsAuthorized capability gate)
Who can grant approvalanyone who can set the statusonly a holder of the operator’s private key
Unsecured is the trusted-author / single-operator convenience posture: you trust whoever writes skills, the status flag is the only barrier. Secured is the enforced boundary — an unapproved or tampered skill cannot perform a single effectful op, regardless of how it’s dispatched (CLI, cron, /event, MCP, in-skill composition). Unsecured is the current default; secured is the recommended posture for any multi-author or networked deployment.
The shell + filesystem allowlists (below) are always enforced, in both modes — they bound what a running skill may touch. Secured mode adds the orthogonal boundary on whether an unapproved skill runs at all.

Asymmetric signing — how secured approval works

Approval is an Ed25519 signature, not a shared secret:
  • Private key — held by the operator, read only by the approve flow. That flow is either skillfile approve at a terminal (the runtime process never loads the key) or, if you opt into in-browser approval, the dashboard’s passcode-gated /approve — which reads the key only after a live human enters the session passcode, never on its own. For maximum isolation, keep signing terminal-side and run the runtime under a uid that cannot read the private key — then a co-resident agent can’t forge approvals even with full read access to the runtime process. Same-uid (or in-browser signing) still works, but isn’t fully isolated (a documented 1.0 precondition; managed custody closes it later).
  • Public key — non-secret; the runtime reads it to verify signatures on every execution. No hot-path secret is the point: verification needs only the public key.
First-run provisioning is automatic — starting the runtime or skillfile init in secured mode generates the keypair if absent (private written 0600, default ~/.config/skillscript/approval.{key,pub}, deliberately outside SKILLSCRIPT_HOME, the agent-readable data dir).

The approve flow

ActionHow
Approve one skill (review the body, then sign)skillfile approve <name> at a terminal
Batch-approve everything pendingskillfile reapprove --apply (dry-run preview: skillfile reapprove)
Approve from the dashboard — terminal signingthe Approvals queue surfaces the pending set, each skill’s security signals, and its effectful footprint; copy the emitted skillfile approve command and run it at your terminal
Approve from the dashboard — in-browserwith SKILLSCRIPT_APPROVAL_PASSCODE set, the queue’s Approve button signs server-side after a one-time passcode unlock
Two signing paths, one custody rule: the private key stays operator-side.
  • Terminal signing. With no passcode configured, the dashboard is a pure review surface — pending queue, per-skill security signals, effectful-footprint checklist — but /approve is disabled and signing happens at your terminal where the key lives. The right posture when the dashboard host and the key-holder’s terminal are the same machine.
  • In-browser signing. With SKILLSCRIPT_APPROVAL_PASSCODE set, the dashboard can sign without a terminal round-trip, and holds no standing signing power: the first approve in a browser session prompts for the passcode (POST /unlock), unlocking signing for that session only (~15-minute idle TTL, server-side, cookie-bound). A live human with the passcode is in the loop for every session — the runtime never gains the ability to approve on its own. Pair it with SKILLSCRIPT_DASHBOARD_AUTH_TOKEN (network hygiene on the dashboard) when the dashboard is reachable beyond localhost.
Either way, skill_write / skill_status cannot grant approval in secured mode — an agent-written or status-flipped skill lands Draft until a key-holder signs it, enforced at the MCP handler regardless of the SkillStore substrate. (In unsecured mode, skill_status → Approved is honored directly, unkeyed.) Misconfiguration is loud. If secured mode and a passcode are both set but signing isn’t actually wired (no SkillStore reaches the dashboard, or no readable key), the dashboard surfaces the gap as a banner and a stderr boot-log line and stays review-only — skills remain inert (fail-closed) while you see and fix it. The boot-log is what reaches a headless / programmatic adopter who never opens the dashboard.

Migrating an existing skill set

Turning secured mode on means any skill that’s Approved without a valid signature is refused at execution. skillfile reapprove sweeps the store, reports the set needing re-blessing, and --apply re-signs them in one pass — so you don’t run skillfile approve once per skill.

Bundled demos

Bundled example skills ship as # Status: Draft — a signature baked at package-build time could never validate on your install (the key is per-operator). skillfile init locally approves the three seeded demos with your machine’s authority (secured → provision keypair + sign; unsecured → bare Approved), so they’re runnable immediately after init.

Deleting skills

Deleting a skill is an operator-only action — there is no agent / MCP delete surface (an agent can author and disable, but only the operator removes). Two surfaces:
SurfaceHow
CLIskillfile delete <name>
Dashboardthe Delete skill button on a skill’s detail view
Both are destructive: the skill and its version history are erased — there is no trash and no restore in the bundled stores (FilesystemSkillStore unlinks the files; SqliteSkillStore hard-cascades both tables). Its triggers are dropped from the live scheduler, and its name frees up immediately for a fresh skill_write/store. The safety is the confirm step plus the reverse-dependency check, not recoverability. Before deleting, both surfaces run a best-effort reverse-dependency scan — which other skills statically reference the target via $ execute_skill(name="…") or inline(skill="…"). If any do, the delete is blocked until you confirm (“triage-flow references this — delete anyway?” in the dashboard; --force on the CLI). The scan is literal-name only: a runtime-resolved name="${VAR}" reference can’t be detected statically, so a dynamic caller may slip past the warning — and because delete is permanent, treat the scan as a heads-up, not a guarantee. Adopter SkillStores implement delete(name) per the contract; the runtime only requires “remove from normal views,” so a soft-delete (tombstone + filter) is a valid adopter-side choice if you need recovery or audit-grade retention — recovery semantics are the store’s concern. See Connector Contract Reference.

Shell binary allowlist

The runtime enforces a default-deny operator allowlist for binaries reachable via shell(...) ops. Skill authors are agents, agents are a weak trust anchor (hallucination, prompt-injection, no human-in-loop at scale), and operator-side scoping converts “a human reviews every skill” from discipline into an enforced constraint at the language level.

The behavior

Two independent operator axes — do not conflate:
AxisOperator switchControls
Binary scopeSKILLSCRIPT_SHELL_ALLOWLISTWhich binaries shell(...) can invoke
Syntax scopeSKILLSCRIPT_ENABLE_UNSAFE_SHELLWhether bash interpretation (pipes / $VAR / $(...)) is permitted
Behavior matrix:
Skill opBinary on allowlistBinary off allowlist
shell(command="X ...") (structural)runsrefused with ShellBinaryNotAllowedError
shell(argv=["X", ...]) (explicit token list)runsrefused with ShellBinaryNotAllowedError
shell(command="X ...", unsafe=true) with enableUnsafeShell=trueruns (if bash on allowlist)refused (off-list bash blocks ALL unsafe shell)
shell(command="X ...", unsafe=true) with enableUnsafeShell=falserefused with UnsafeShellDisabledError (syntax axis fires first)
Off-allowlist is final. The skill author has no in-skill mechanism to escape it — not the unsafe keyword, not # Autonomous: true, not approved="reason". Binary scope is an operator boundary the author cannot talk past.

Three call shapes — command=, argv=, unsafe=true

Three ways to invoke a binary. Each picks a different point on the safety/expressiveness curve:
FormWhen to reach for it
shell(command="curl -s https://example.com/${ID}") -> RSimple commands, no spaces or quote-special chars in substituted values. The string is whitespace-tokenized + quote-stripped; structural spawn (no shell).
shell(argv=["say", "-v", "${VOICE}", "-f", "${PATH}"]) -> RArgs with spaces, JSON payloads, dynamic strings. Each list element is exactly one argv token; substitution per element doesn’t re-tokenize. Strictly safer than unsafe=true — no shell process, no metacharacter interpretation, injection-surface zero. The right tool when a ${VAR} may contain whitespace.
shell(command="echo hi && date", unsafe=true) -> RPipes, redirects, shell built-ins, command-substitution. Requires enableUnsafeShell: true at the runtime; lint flags every appearance (tier-2 unsafe-shell-op).
argv= is mutually exclusive with command= and with unsafe=true (parse errors on either combination — argv is execv-class, there’s no shell to opt into). The same allowlist gate applies to argv[0].

Quote-trap lint (shell-quoted-var-in-command)

The pattern shell(command="echo '${VAR}'") looks safe — quotes around the substitution — but the structural tokenizer respects quotes during the original whitespace split, then strips them. After substitution, if VAR contains quote characters (Jamie's) the matching can drift. Tier-2 lint shell-quoted-var-in-command fires on this pattern and points at the argv= form as the safer answer. Works fine for known-simple values; the lint exists because the failure mode is silent-wrong, not loud-error.

Discovering your corpus’s binaries

The default-deny posture means a freshly-installed runtime with no allowlist wired refuses every shell() call. Use skillfile shell-audit to enumerate the binaries your skill corpus invokes:
skillfile shell-audit

# Sample output:
#   Scanned 12 skill(s) under /Users/you/.skillscript/skills.
#
#   Binaries used:
#     curl   (in: weather-fetch, status-probe)
#     git    (in: status-board)
#     jq     (in: weather-fetch, support-response, status-probe)
#     bash   (in: support-response)
#
#   Ready-to-paste .env entry:
#
#   SKILLSCRIPT_SHELL_ALLOWLIST=bash,curl,git,jq
#
#   Note: 'bash' is on the list because at least one skill uses
#   shell(..., unsafe=true). To permit unsafe shell, ALSO set
#   SKILLSCRIPT_ENABLE_UNSAFE_SHELL=true.
Paste into your $SKILLSCRIPT_HOME/.env (review/narrow as desired). The audit is the canonical path — running it lets you make explicit policy decisions instead of discovering refusals through runtime errors.

Programmatic bootstrap path

Most adopters operate via the web dashboard after a one-time skillfile init. To stand that dashboard up from your own code, use bootstrapFromEnv() — the blessed entry point that wires a full runtime + DashboardServer from $SKILLSCRIPT_HOME exactly the way the CLI does: it loads .env, skillscript.config.json, and connectors.json, resolves the whole SKILLSCRIPT_* env cascade, calls bootstrap(), wires declarative triggers, and assembles the server.
import { bootstrapFromEnv } from "skillscript-runtime";

const { wired, server } = await bootstrapFromEnv({ mode: "dashboard" /* or "serve" */ });
wired.scheduler.start();
await server.start();
// On shutdown, reap stdio-bridged connector children (RemoteMcpConnector):
process.on("SIGTERM", async () => {
  await wired.scheduler.stop();
  await server.stop();
  await wired.registry.disposeAll();
});
Both are returned unstarted — you decide when to start(). Options: mode, home (default $SKILLSCRIPT_HOME), configPath, connectorsConfigPath, port, host, and overrides. Precedence: explicit option > overrides (passed to bootstrap(), wins last) > SKILLSCRIPT_* env > skillscript.config.json > default. To wire your own custom substrate (e.g. a remote SkillStore), two paths:
  • Declarative — if it’s expressible in connectors.json via the { "type": "custom", "module": "./my-store.js", "export": "MyStore" } substrate form, just declare it; bootstrapFromEnv() honors substrate.* like the CLI.
  • Instance injection — pass a pre-built instance through overrides, the common path for a store that needs constructor wiring you hold in code:
    const { wired, server } = await bootstrapFromEnv({
      mode: "dashboard",
      overrides: { skillStore: new MyRemoteSkillStore({ /* client, auth, ... */ }) },
    });
    
    Everything else (data store, models, connectors, env cascade) still auto-wires from $SKILLSCRIPT_HOME. overrides accepts any bootstrap() option (skillStore / dataStore / localModel / agentConnector / allowlists / …).
This closes the silent CLI-vs-programmatic asymmetry described below; reach for raw bootstrap() only when you’re hand-assembling a registry that neither connectors.json nor overrides can express. ⚠ Migrating an existing hand-assembled bootstrap to bootstrapFromEnv()? Move any options you previously hardcoded on bootstrap() / new DashboardServer({...}) to their SKILLSCRIPT_* env equivalents — bootstrapFromEnv() resolves them from env, so a value you drop reverts to the default. Two to watch (adopter-verified, 0.24.0): enableUnsafeShell: trueSKILLSCRIPT_ENABLE_UNSAFE_SHELL=true (fails loud — unsafe ops just refuse, you’ll notice); and mcpCallerIdentityHeader: "X-Agent-Id"SKILLSCRIPT_MCP_CALLER_IDENTITY_HEADER=X-Agent-Id, which fails silently — drop it and skill-author attribution quietly reverts to the store’s default writer identity, with no error. (bootstrap()-level opts like enableUnsafeShell can instead go through overrides; the DashboardServer-level mcpCallerIdentityHeader is env-only via bootstrapFromEnv — set the env var.) Verify after migrating: send your identity header on a /rpc skill_write and confirm the captured author.

Raw bootstrap() — the CLI-auto-vs-programmatic-explicit asymmetry

bootstrapFromEnv() does the discovery steps below for you. If you call bootstrap() directly instead (hand-assembling substrates), be aware the CLI (skillfile dashboard / serve) does several discovery steps automatically — load $SKILLSCRIPT_HOME/.env, read SKILLSCRIPT_* env vars, load $SKILLSCRIPT_HOME/connectors.json — that raw bootstrap() does NOT. Each surface then needs explicit wiring:
SurfaceCLI pathProgrammatic path
.env (env vars in a dotfile)auto-loaded from $SKILLSCRIPT_HOME/.envcall process.loadEnvFile() yourself BEFORE bootstrap()
SKILLSCRIPT_* env varsread from process.env automaticallyread automatically once env is loaded (bootstrap() env-fallback)
connectors.json (MCP wiring)auto-discovered at $SKILLSCRIPT_HOME/connectors.jsonpass connectorsConfigPath: "/path/to/connectors.json" to bootstrap()
Skip any of these and you get silent-empty surprises — a missing env var, an undeclared connector, etc. — with no hint that the file wasn’t loaded. This is intentional: bootstrap() stays decoupled from the dotenv + SKILLSCRIPT_HOME convention so embedders can drive every input explicitly. The cost is doc-prominence — three surfaces, all silent-empty on omission, all noted here. For your shell allowlist to work on the programmatic path, ensure the env var is in process.env BEFORE calling bootstrap(). Two patterns:
// Pattern A — load .env yourself + pass connectorsConfigPath
import { join } from "node:path";
import { bootstrap } from "skillscript-runtime";

const home = process.env.SKILLSCRIPT_HOME ?? join(homedir(), ".skillscript");
try { process.loadEnvFile(join(home, ".env")); } catch {}  // Node 22+ built-in
const { mcpServer, scheduler } = bootstrap({
  skillsDir: join(home, "skills"),
  traceDir: join(home, "traces"),
  connectorsConfigPath: join(home, "connectors.json"),
  // shellAllowlist intentionally omitted → bootstrap reads env
});

// Pattern B — fully explicit (env-independent, no .env, no connectors.json)
const { mcpServer, scheduler } = bootstrap({
  skillsDir: join(home, "skills"),
  traceDir: join(home, "traces"),
  shellAllowlist: ["curl", "git", "jq"],
  // No connectorsConfigPath — adopter constructs Registry by hand
});
bootstrap() env fallback semantics: when opts.shellAllowlist === undefined, the runtime reads SKILLSCRIPT_SHELL_ALLOWLIST from process.env (comma-separated, trimmed). When opts.shellAllowlist is supplied — including the explicit [] deny-all — the option is authoritative and env does NOT widen it. This is security-load-bearing: an adopter passing shellAllowlist: [] to assert lockdown gets lockdown regardless of ambient env.
opts.shellAllowlistSKILLSCRIPT_SHELL_ALLOWLIST envEffective allowlist
undefined (omitted)unsetundefined → default-deny
undefined (omitted)"curl,jq"["curl", "jq"] (env fallback)
undefined (omitted)"" (explicit empty)[] (explicit deny-all from env)
["curl"]anything["curl"] (explicit opt wins)
[] (explicit)"ssh,kubectl"[] (explicit deny-all wins; env does NOT widen)

Trust model — lint vs. runtime

Lint is local advisory; runtime is authoritative. The shell-binary-not-allowed lint rule checks against the author’s environment allowlist (their local .env or the linter’s loaded config), which may differ from production. Passing lint does NOT guarantee the call will run.
  • Authors lint with their own allowlist for immediate feedback (“you can’t use ssh in your dev env, fix this skill”).
  • CI pipelines lint with the deployment’s .env to catch production-specific gaps before merge.
  • Runtime is the authoritative gate — refuses off-list binaries at first dispatch regardless of lint state.
This split is intentional: lint as discipline-only would let adopters disable it; runtime as the boundary means the constraint always holds.

The scope caveat — don’t oversell

The allowlist scopes which binaries are reachable, not what they do. Kills ssh / kubectl / rm / nc wholesale — that’s real value. Does NOT stop misuse of a permitted binary: curl on the list still means curl-to-anywhere (exfil possible to attacker-controlled URLs). Argument-level policy (host allowlists, URL pattern matching) and OS-level egress controls are separate, heavier layers — explicitly out of scope here. Don’t let this create false confidence that shell is “solved.” For the unsafe path, the proper binary-scope layer is OS-level:
  • Restricted PATH (only allowed binaries reachable to the runtime process)
  • execve/seccomp filters (kernel-level allowed-binary set)
  • Container with curated /usr/bin (immutable distroless image)
Parse-based binary enumeration of bash -c <body> would be unsound — e=curl; $e ..., $(printf cur)l ..., eval, xargs, var-built command names all defeat it. Against agent-author threat models, false confidence is worse than no enforcement.

Dashboard observability

The dashboard SPA at http://<host>:<port> exposes two security-focused surfaces for the observe→promote loop: Security view (#security route). Cross-skill list of blocked shell attempts — {skill, target, binary, body, timestamp} per refused call. Aggregated by binary so you can see at a glance “what did skills try to invoke that I haven’t allowlisted.” Backed by the blocked_shell_attempts MCP tool, which filters trace records by blocked_reason: "binary-not-allowed". Runtimes without the tool exposed degrade cleanly to an “unavailable” note. Skill detail view — footprint checklist + security signals + source highlighting. Each skill’s detail page (clicking a skill name from #skills) shows:
  • A “What this skill touches” panel right at the approve action: the skill’s effectful footprint — the MCP connectors it dispatches to, built-in $ ops, shell binaries, and counts of file_write / file_read / unsafe-shell / notify ops, or “nothing effectful to authorize” for a pure skill. This is AST-derived — the same op enumeration the capability gate authorizes — so it’s the authoritative surface the skill can touch once signed, not a textual guess. The least-privilege checklist the approver reads before signing.
  • A “Security signals” panel: aggregated counts of shell ops + binaries used, unsafe-shell count, # Autonomous: true, per-op approved="..." authorizations, mutation ops ($ skill_write / $ data_write / file_write), wake-class @session deliveries, cron triggers. (A heuristic source scan — it adds risk-framing signals the footprint doesn’t, e.g. the approved= bypass and the autonomous flag; the two panels complement each other.)
  • Inline tinted highlighting on the skill source <pre> body. Two tiers: orange for HIGH-tier signals (unsafe=true, # Autonomous: true, approved="...", mutation ops); yellow for MEDIUM-tier signals (shell(...) calls, notify(agent="X@session", ...) wake-class deliveries). Reviewers scan-prioritize: orange = review carefully; yellow = worth noting.
The surfaces compose: the footprint says WHAT the skill can do, the security-signals panel says WHAT to scrutinize, the highlights say WHERE to look. The same footprint rides every skill_list entry and the skill_preflight contract, so an agent composing against a skill reads the same truth the human approver does.

Future direction

Per-skill capability declaration: skills declare what shell binaries they need in their frontmatter:
# Skill: status-board
# Shell: git, jq
# Status: Approved
The operator policy validates declared ∩ allowlist — each skill’s shell footprint becomes self-documenting and auditable. Slated for a future ring once the chokepoint + observability surfaces ship.

Filesystem path allowlist

The runtime enforces a default-deny operator allowlist for paths reachable via file_read(...) / file_write(...) ops — the third operator boundary, mirroring the shell allowlist with the same rationale (skill authors are agents; the operator scopes which parts of the filesystem skills may touch).
Operator switchControls
SKILLSCRIPT_FS_ALLOWLISTComma-separated roots under which file_read / file_write may operate
  • Default-deny. An unset or empty allowlist refuses every file op with FilePathNotAllowedError — a freshly-installed runtime does no file I/O until you wire roots. Applies to file_read as well as file_write (a read-then-emit is an exfiltration path).
  • Canonicalized before the check. Both the target and each allowed root are resolved to their real absolute path (realpath, component-by-component), so .. traversal and symlink evasion can’t escape an allowed root — the classic allowlist bypasses are closed.
  • Off-allowlist is final. No in-skill keyword (approved=, # Autonomous: true) escapes it. Keep secret / key directories OUT of the allowlist — a skill must never be able to read the operator’s approval key. (This is why the default key path lives outside SKILLSCRIPT_HOME.)
# permit reads/writes under a workspace + an event-drop dir
SKILLSCRIPT_FS_ALLOWLIST=/srv/skillscript/workspace,/var/skillscript/events
TOCTOU note: the check resolves the real path at call time; a symlink swapped between check and open is a residual closed by fd-based opens later. Checking the resolved real path is the standard mitigation shipped today.

Secrets

A skill can reference an operator-provisioned secret by name, use it at a sink, and never read it back. This is how a credential (a bearer token, an API key) reaches a shell(...) or $ connector.tool call without living in the skill source, the transcript, or a trace. Three parts:
# Skill: send-mail
# Requires: secret.AGENTMAIL_KEY        # 1. declare — an approver sees which credentials the skill reaches
# Status: Draft

default: run
run:
    shell(command="curl -sS -H 'Authorization: Bearer {{secret.AGENTMAIL_KEY}}' https://api.agentmail.to/...") -> R
  1. Declare with # Requires: secret.NAME.
  2. Place with a {{secret.NAME}} marker — only inside a sink (a shell(...) op or a $ connector.tool arg). It is deliberately distinct from ${VAR}: ${VAR} is readable substitution over the skill’s variable scope and cannot reach a secret; {{secret.NAME}} resolves only at the sink and is never bound to a variable.
  3. Provision the value as an env var (operator side, e.g. .env):
SKILLSCRIPT_SECRET_AGENTMAIL_KEY=am_live_...
Operator switchControls
SKILLSCRIPT_SECRET_<NAME>The value {{secret.NAME}} resolves to. The SKILLSCRIPT_SECRET_ prefix scopes which env is secret-reachable — {{secret.PATH}} looks for SKILLSCRIPT_SECRET_PATH, never $PATH.
Use-only, enforced. A marker is legal only in a sink; the runtime injects the value for that one call and never lets it reach a readable surface. Three lint rules block misuse at compile (secret-use-only — a marker in emit/$set/a condition/an # Output: template/a file_*/notify op/an op (fallback:); secret-undeclared — a marker with no # Requires; secret-dynamic-name — a non-literal name like {{secret.${VAR}}}). The runtime is the authoritative gate (it survives a lint bypass): it resolves only names declared in # Requires, only at the sink, and the trace + any error message render the {{marker}}, never the value. Fail-closed — an unprovisioned or undeclared secret aborts the op. Provisioning is operator-only. A skill author names a secret; only the operator sets it (in the runtime env / .env). Keep secret values out of skill source and out of the fs allowlist, exactly like the approval key. Threat-model boundary — read this before granting a secret. A secret you authorize a skill to use in a shell, that skill can also exfiltrate — e.g. shell("echo {{secret.X}}") -> OUT then emit("${OUT}"), or a curl that POSTs it to any host. This is inherent to passing a credential into a shell command and is not prevented by the runtime. Two controls:
  • Approval review. In secured mode a human approves the skill before it runs — review shell-sink skills for exactly this (a command that echoes or forwards the credential).
  • Prefer a connector sink. A $ connector.tool where the connector holds the credential out of band is stronger than a shell curl: the value never round-trips through skill-visible stdout. Use a connector sink for any credential whose exposure you can’t accept.
(On the shell path the resolved value is also briefly visible in the host process list via ps; a future vault-backed SecretProvider closes that with sink-aware injection. The SecretProvider seam is already in place — an adopter can supply a vault-backed provider via bootstrap({ secretProvider }) or bootstrapFromEnv’s overrides with no skill changes.)

Trigger model

The trigger surface is two primitives.
PrimitivePurpose
cronTime-based fires (unchanged)
eventGeneric external-signal fires via HTTP POST to /event
Concepts that look like triggers but aren’t — session lifecycle, agent events, file-watch, sensors — are adapter responsibilities: external code POSTs /event when relevant. Keeps the runtime substrate-neutral; the trigger surface stays tight. The DeliveryMeta.origin.trigger_kind receiver enum exposes only "cron" | "event" | "webhook" | "agent" | "cli" | "dashboard" | "inline".

The event primitive

Skills declare an event trigger in their frontmatter:
# Skill: prox-handler
# Status: Approved
# Triggers: event: prox
# Vars: ROOM, USER

t:
    emit(text="${USER} entered ${ROOM}")
default: t
External services drive the skill by POSTing:
POST /event HTTP/1.1
Host: localhost:7878
Content-Type: application/json
Authorization: Bearer <token>   # if SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN set

{
  "event_name": "prox",
  "params": { "ROOM": "kitchen", "USER": "alice" }
}
Response on accept:
{ "run_id": "f4a8b21c-...", "durability": "in-process" }
The run_id is the runtime’s trace_id — adopters paste it into the dashboard /fires view or query via the fires({trace_id}) MCP tool to look up completion status.

Wire contract

HTTP statusMeaning
200Accepted into THIS process’s in-memory queue. {run_id, durability: "in-process"}. Skill fires async; the 200 does NOT mean skill-completed
400Body not JSON; or event_name missing/empty; or params don’t match declared (missing required or unknown extras)
401Auth token configured + missing/wrong Authorization: Bearer <token>
404/event route not enabled OR event_name not registered
405Wrong method (POST-only)
Param validation is strict v1: every declared param must be present; no unknown params accepted. No defaults; no type coercion. JSON already carries types, and a type mismatch fails inside the consuming op with a real error. Defaults + types may land in v2 if real adopter need surfaces.

# Autonomous: true for event/cron skills doing mutations

Skills fired by cron OR event have no interactive author to confirm mutation ops. The runtime’s mutation gate ($ data_write / $ skill_write / file_write / mutating MCP tools) requires explicit authorization in non-interactive contexts. Three authorization paths exist:
# Option A — skill-level: # Autonomous: true (recommended for cron/event skills)
# Skill: morning-sweep
# Status: Approved
# Triggers: cron: 0 9 * * *
# Autonomous: true

m:
    $ data_write content="..." tags=["digest"] -> R
    emit(text="sweep done")
default: m
# Option B — per-op: approved="<reason>" kwarg
# Skill: event-handler
# Status: Approved
# Triggers: event: incident-report
# Vars: SEVERITY

m:
    $ data_write content="${SEVERITY}" approved="event-fired" -> R
default: m
Option C (preceding ?? / ask() in same target) is for interactive contexts (CLI / dashboard) and doesn’t apply to cron/event fires — there’s no user to ask. Without one of these, the runtime throws UnconfirmedMutationError at the mutation op + the skill fails its fire. Symptom in the trace: class: "UnconfirmedMutationError" on the offending op. The mutation gate is identical for cron + event sources — both are non-interactive. Lint surfaces unconfirmed-mutation as tier-2 warning at compile time so authors get the signal before the first fire.

event_name semantics

  • Public contract addressed by POSTers. Skill behind the event can swap without breaking callers — the skill_name is private impl.
  • Case-insensitive at register + lookup (normalized to lowercase internally).
  • 1:1: one event_name → exactly one skill in the deployment. Fan-out is NOT supported — a skill can call other skills via $ execute_skill if needed.
  • Cross-skill rebind allowed but audited: registering an event_name that’s already bound to a different skill replaces the binding (last-write-wins) AND logs a visible line (event_name 'X' rebound: skill A → skill B). Prevents silent cross-skill hijack without blocking the declarative re-save path. Same skill re-registering its own event is a silent upsert.

Enabling the /event HTTP ingress

Off by default. Two operator knobs:
# Required to open the /event route
SKILLSCRIPT_EVENT_INGRESS_ENABLED=true

# Optional bearer-token auth (when set, required on every POST /event)
SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN=<token>
When eventIngressEnabled=true, the route mounts on the same HTTP server as the dashboard/RPC (no second port). Default bind is 127.0.0.1 (localhost-only) — adopters wanting 0.0.0.0 external reach pass --host 0.0.0.0 explicitly. The DMZ assumption is enforced by the bind, not by hope.

Durability honesty

**200 = ACCEPTED, not durable**.** The skill is queued in this process's memory; if the process crashes or restarts before the queue drains, the fire is lost. The durability: “in-process”` field on every successful response self-describes this — adopters reading the response know the contract without consulting docs. Cron triggers have the same property today (no catch-up replay if the process was down). Durable / at-least-once delivery is a v2 if real adopter need surfaces. Don’t oversell the 200 contract; build durable queueing on top yourself if you need it now.

Bridging external sources

Anything that isn’t time-based is an external adapter that POSTs to /event:
  • Session start/end — your harness POSTs /event after boot or before shutdown if you want a skill to fire at session boundaries.
  • Agent events — your agent emits POST /event when the event of interest occurs.
  • File-watch — an external watcher script (inotify / chokidar / fswatch) POSTs to /event on changes. The file-watching itself is standard OS plumbing — you own that script.
  • Sensors — same pattern as file-watch; the sensor adapter POSTs to /event.
Skills declaring unsupported # Triggers: sources fail to parse with a tier-1 parse error. skillfile lint against your corpus surfaces them.

Output template — body text IS the output

A skill’s body text — prose lines that don’t sit inside a target — is its declarative output template. The runtime interpolates ${VAR} references against final vars and publishes the rendered string as the skill’s canonical output (outputs.text or the agent/template/file payload, depending on # Output: kind). No emit() ceremony required for the common case. The template may appear anywhere a target doesn’t — at the top, at the bottom, or split between targets — so authors can put output where their language instincts suggest (most write the output line after the compute that fills it in).

The shape

# Skill: get-weather
# Vars: AREA="Brooklyn", TEMP="72", UNIT="F", DESC="sunny"

${AREA}: ${TEMP}°${UNIT} and ${DESC}.

fetch:
    shell(command="curl -s --max-time 10 https://wttr.in/${AREA|url}?format=j1") -> RAW
    $ json_parse ${RAW} -> W
    $set TEMP = "${W.current_condition.0.temp_F}"
    $set DESC = "${W.current_condition.0.weatherDesc.0.value|trim}"
default: fetch
Strip the compute block and the skill still emits the template with whatever the # Vars: defaults are. The author writes the sentence they want as output; the compute block fills in the holes.

Complementary channels — template vs. emit

Two independent output channels:
ChannelSourceConsumer
Canonical output (outputs.text, agent/template/file payloads)Body template if authored, else joined emit() textThe caller / lifecycle-hook recipient / file write
Transcript (emissions[])All emit(text=...) calls + reasoning ops (?, @)Trace records, dashboard /fires view, debug log
When a skill defines both a template AND emit(text=...) calls, the template owns canonical output; emit() populates transcript only. This is intentional — emit is the debugging / reasoning channel. The emit-with-template advisory lint surfaces this to confirm intent. If you want emit to be your canonical output, don’t write a body template. Emit-only skills route emissions to the canonical output channel.

Output kinds

The body template populates whatever # Output: kind the skill declares:
  • # Output: text (default) — caller reads outputs.text
  • # Output: agent: <name> — rendered template delivered as augment payload to <name> via AgentConnector
  • # Output: template: <name> — rendered template delivered as playbook payload
  • # Output: file: <path> — rendered template written via file ops in the body

Target syntax — what counts as a target

A target is <name>: alone on a line, with an indented op-block on the next non-blank line. Anything else in the body region — content after a colon, a bare Note: with no following op-block, plain prose — reads as template text. Two examples that are legal but worth knowing:
Summary: today is hot.        ← template (content after colon, no op-block)
Temp: ${T}                    ← template (content after colon)

Note:                          ← AMBIGUOUS — lint `template-looks-like-target` fires
                                  bare word: alone, no op-block. Disambiguate by adding
                                  content after the colon OR indent an op-block under it.
Target declarations with dependencies still work via the explicit needs: keyword, which makes intent unambiguous:
report: needs: fetch_issues
    emit(text="…")
The legacy report: fetch_issues shape (deps after colon, no needs: keyword) is still parsed as a target when an indented op-block follows on the next line, but needs: is the recommended form for new skills.

One template region per skill

A skill may carry template prose in one region. Splitting it into two — some prose above the targets AND some below — raises a tier-1 parse error: “skill has body template content in two places (before targets AND after targets). Pick one location.” Picking is loud over silent-concat; the author chooses where the template lives, the runtime doesn’t guess.

Lints to know

  • unset-template-var (tier-1) — every ${VAR} in the template must resolve to a # Vars: / # Requires: input, an ambient ref (NOW, USER, …), or a $set / -> binding somewhere in the skill body. Tier-1 because an unbound ref renders empty silently.
  • template-looks-like-target (tier-2) — bare <word>: alone in the template region, no following op-block. Ambiguous shape — author may have meant a target.
  • connector-as-tool (tier-1) — $ <connector> <tool> space-separated catches the muscle-memory foot-gun (CLI-style git status). Bare-form dispatch treats the first token as the tool name, sending name: "<connector>" to the MCP server, which replies with a misdirecting Tool '<connector>' not found. The two correct shapes are $ <connector>.<tool> args (dotted) or $ <tool> args (bare-tool; runtime resolves).
  • remote-result-needs-parse (tier-3) — ${R|length} on an R bound by $ dispatch. Per-MCP-server result shapes vary: if the server returns prose-wrapped or non-JSON text, the value binds as a STRING and |length returns the string’s char-count instead of element-count. Add $ json_parse ${R} -> P after the dispatch and use ${P|length}. Suppressed when the skill already does $ json_parse ${R} somewhere — your defensive parse is taken as intent.
  • body-template-detected (tier-3) — non-blank, non-# lines in the body region, no ${...} interpolations, no text-consuming # Output: declaration. Suggests “I wrote prose; it became template by accident.” Prefix with # to mark as comments, or add an interpolation / # Output: to confirm intent.
  • emit-with-template (tier-3) — skill has both a template AND emit(text=...) calls. Confirms the channel-shift is intentional.

Fallback semantics — (fallback: ...) op-trailer and |fallback: filter

Two surfaces, one emptiness predicate. Both fire when the upstream value is any of:
  • empty string after trim()
  • empty array ([])
  • null / undefined

Op-trailer — (fallback: "value")

Trails the -> R binding on $ dispatch ops, shell(...), file_read(...). Binds the fallback value to R when the op throws OR produces an empty result.
$ ticketing.search query="open" -> ISSUES (fallback: [])

shell(argv=["gh", "pr", "list", "--repo", "owner/repo"]) -> PRS (fallback: "No current PRs.")

file_read(path="/var/reports/today.md") -> REPORT (fallback: "no report")
When the fallback fires, the runtime pushes a record onto result.fallbacks[] so callers can distinguish “real value” from “fallback substituted.” The original error message rides on the fallback record’s reason field; the op completes cleanly.

Filter — ${VAR|fallback:"value"}

Substitution-time fallback inside a template or kwarg. Same emptiness predicate; binds to the filter argument when the upstream value is empty.
Open PRs: ${PRS|fallback:"none today"}.

$ llm prompt="Triage this: ${INPUT|fallback:'(no input — skip)'}" -> JUDGMENT
The filter is positional in chains: ${RAW|trim|fallback:"-"} applies trim first, then fallback if the trim left an empty string.

When to use which

  • Op-trailer for binding-time protection — the variable lands populated regardless of the op’s outcome. Downstream consumers (other ops, template renders) don’t need to know whether the value is real or fallback.
  • Filter for substitution-time defaulting — the variable may legitimately be empty/unset, you just want a placeholder at the render site.
Most “no current results” patterns want the op-trailer (the variable becomes the canonical record of what happened). The filter is for “if the optional input wasn’t supplied, show a placeholder” cases.

Wiring the AgentConnector

AgentConnector is the substrate-neutral delivery surface for # Output: agent: X / # Output: template: X lifecycle hooks and notify() / exchange() ops. The runtime calls into the contract; your impl decides where the payload lands (webhook, tmux session, file drop, IPC pipe, Slack thread, your own agent harness, etc.). The full contract surface — methods, payload shapes, receipt shapes, the agent@session targeting convention, the graceful-degradation rule — lives in Connector Contract Reference §AgentConnector. This section covers the wiring path for adopters: how to bring an impl online so the runtime uses it.

Two wiring paths

Same shape as the other substrate slots — programmatic (recommended for custom impls today) or declarative (connectors.json, restricted to bundled types). (a) Programmatic — for adopter-written impls. Construct the connector in your bootstrap and pass it via BootstrapOpts.agentConnector:
import { bootstrap } from "skillscript-runtime";
import { MyAgentConnector } from "./my-agent-connector.js";

const { registry, scheduler, server } = await bootstrap({
  agentConnector: new MyAgentConnector({
    endpoint: process.env["MY_AGENT_ENDPOINT"],
    api_key: process.env["MY_AGENT_API_KEY"],
  }),
});
bootstrap() calls registry.registerAgentConnector("primary", ...) for you. health_check() fires during registration — wiring failures throw at boot, not at first delivery. (b) Declarative connectors.json — for bundled types and the (deferred) custom-via-dynamic-import path:
{
  "substrate": {
    "agent_connector": "noop"
  }
}
Bundled short-form values:
ValueBehavior
null (or omitted)NoOpAgentConnector — silent fallback. deliver() / wake() log + resolve; # Output: agent: declarations complete with a stderr warning. Lets a runtime start with no harness wired.
"noop"Same as null but explicitly stated.
Object with "type": "custom"Adopter impl resolved by dynamic-import (deferred — surfaces a clear error today; use programmatic path).
For full configuration shape, see Configuration §“The substrate section.”

Precedence

Same as other substrate slots:
  1. Programmatic BootstrapOpts.agentConnector — explicit, highest priority.
  2. Declarative connectors.json substrate.agent_connector — deployment-durable.
  3. Built-in defaultNoOpAgentConnector. Skills with # Output: agent: fire warnings, not errors.
The NoOp fallback is the design choice that makes “runtime works out of box without any AgentConnector wiring” hold. Adopters who want strictness should explicitly wire their connector and let health_check() throw if it can’t start.

Worked example

The canonical bundled example is examples/connectors/HttpWebhookAgentConnector/ — a complete AgentConnector impl against an HTTP-webhook substrate. It demonstrates:
  • Per-agent URL routing (HTTP_WEBHOOK_AGENTS JSON env)
  • Optional wake_url per agent — present means wake-capable, absent means degrade-on-wake
  • Bearer + HMAC auth (combinable)
  • Tolerant receipt synthesis (substrate returns substrate-shaped JSON; connector translates to canonical DeliveryReceipt)
  • Tests covering the deliver / wake / health-check / request-response surface
Three patterns to copy when forking it for your substrate: Pattern 1 — agent@session opaque composite. Every messaging substrate needs either bare-identity OR specific-live-session addressing. The contract keeps agent_id opaque; sessions ride as "<agent>@<session>" (e.g. "alice@laptop-tab-3") or via WakeOpts.session_id. Substrates that care decompose; substrates that don’t ignore. The runtime address-routes skill-author surfaces (notify() + # Output: agent: / template:) on @session presence: bare → your deliver(), composite → your wake(). You receive whichever method the runtime decided; your job is to honor what arrives. For wake(), expect the FULL composite ("<agent>@<session>") — decompose to route to the right session:
async wake(agent_id: string, opts?: WakeOpts): Promise<WakeReceipt> {
  // Form A — composite in agent_id
  const [agent, embeddedSession] = agent_id.split("@");
  // Form B — opts.session_id wins if both supplied
  const session = opts?.session_id ?? embeddedSession;
  // ... route to (agent, session)
  return { woken_at: Date.now(), woken: true, ...(session ? { session_id: session } : {}) };
}
Pattern 2 — graceful degradation on wake. wake() must not throw because your substrate lacks interrupt capability. Distinguish capability-gap (degrade) from operational-fault (throw):
async wake(agent_id: string, _opts?: WakeOpts): Promise<WakeReceipt> {
  const cfg = this.agents[agent_id];
  if (!cfg) throw new Error(`agent not configured: ${agent_id}`);  // operational fault
  if (!cfg.wake_url) {
    // capability gap — no interrupt channel for this agent — degrade
    return { woken_at: Date.now(), woken: false };
  }
  const response = await fetch(cfg.wake_url, { ... });
  if (!response.ok) throw new DeliveryFailedError(...);  // operational fault
  return { woken_at: Date.now(), woken: true };
}
Callers reading WakeReceipt.woken distinguish “the substrate woke them” from “the substrate stored the payload for later” without needing per-substrate knowledge. Pattern 3 — session echo on receipts. When your substrate routes to a specific session, echo it back on DeliveryReceipt.session_id / WakeReceipt.session_id. Dashboards rendering “delivered to alice@laptop-tab-3” rather than just “delivered to alice” depend on this. Pattern 4 — read meta.origin.caller_agent_id to attribute, not for scope. The DeliveryMeta envelope your deliver() receives carries origin.caller_agent_id = the authenticated caller who fired the dispatch (not the skill’s owner — those are separate semantics; see Connector Contract Reference §field semantics). Use it for attribution — rendering from <caller> on the receiving end, audit logs, accountability — not for authorization scoping. Outbound substrate scoping should derive from the skill owner (which the runtime applies at the connector layer via ctx.agentId, not via the envelope). If caller_agent_id is undefined on a delivery you receive, it means the chain originated from a non-human trigger (cron / event / scheduler) — your substrate should attribute it as “system-fired” or similar, not assume an identity. Pattern 5 — surface non-fatal notes via DeliveryReceipt.warnings. When your substrate needs to signal something non-fatal about a delivery — “stripped @session because verb is deliver”, “rate-limit hint”, “fan-out: delivered to 3 sessions” — return them as warnings: string[] on the receipt instead of writing to stderr. The runtime echoes warnings onto AgentDeliveryReceiptRecord.receipt.warnings, where the dashboard can render them and observability tools can scrape them. Stderr noise gets lost; receipt warnings are structured + caller-visible.

When to fork vs. when to write fresh

  • Fork HttpWebhookAgentConnector when your substrate is HTTP-shaped and your changes are: tweaked auth (OAuth, mTLS), retry policy, different routing model. Most production deployments end up here.
  • Write fresh when your substrate is fundamentally non-HTTP (tmux, file drop, gRPC, websocket-push). Implement the five required methods (list_agents, deliver, wake, health_check, request_response) + optional agent_status. Use NoOpAgentConnector as the minimal-shape reference.
In either case: write tests against the contract methods (the bundled example’s tests/HttpWebhookAgentConnector.test.ts is a useful template), wire via BootstrapOpts.agentConnector, and let health_check() enforce the “fail-at-boot, not at first delivery” property.

Authoring posture — who owns the skills you write

Every skill stored in a SkillStore carries a SkillMeta.author field captured at first-write. The author is then load-bearing at dispatch time: the runtime threads it into ctx.agentId so identity-scoped substrates (memory stores, multi-tenant DBs) read and write under that scope. How author is captured depends on how the skill gets written:
  • CLI / dashboard / direct programmatic API. When you call SkillStore.store(name, body) from your own code (CLI, bootstrap, scripts) or via the dashboard’s approval flow, the SkillStore captures author from its bundled default. FilesystemSkillStore uses os.userInfo().username; adopter stores capture from their own auth context.
  • MCP skill_write from a single-tenant host. If only one agent (or one human) calls your runtime, you don’t need to configure anything — the SkillStore.store() default-author logic above applies. Skip the multi-agent section below.
  • MCP skill_write from a multi-agent host. If multiple agents share one runtime instance via MCP (e.g., a host that bridges several authenticated agents into one transport), the runtime can’t tell them apart at the protocol layer. See the next section.

Direct-write authoring path

Adopters whose SkillStore is backed by an addressable substrate (e.g., a memory store) can author skills by writing the substrate record directly — without going through the MCP skill_write handler. This captures SkillMeta.author from the substrate’s own writer-identity (whatever the direct-write API authenticates as). Gotcha: in secured mode, a direct-write declaring # Status: Approved without a valid signature is forced to Draft — the substrate can’t mint approval, only the operator’s key can. Publish by writing Draft, then signing via skillfile approve <name> or the dashboard Approvals queue (both preserve the captured author). In unsecured mode a bare # Status: Approved direct-write is honored as-is. Either way, in-skill $ skill_write always lands its child Draft regardless of body declaration. See Approval + secured mode.

Discovering connector tools while authoring

When a skill dispatches an MCP connector tool ($ <connector>.<tool> ...), the runtime helps an author get the call right without a guess-and-run loop:
  • runtime_capabilities lists the wired connectors and their tool names (a compact menu). Pull one tool’s full argument schema on demand with runtime_capabilities({ tool: "<connector>.<tool>" }) — the manual for the tool you’re about to call, kept out of the default response so it stays small.
  • lint_skill validates the args you wrote against that schema: an unknown arg name ($ ddg.search querry=...) or a missing required arg surfaces as a tier-2 warning at author time instead of failing at dispatch. Degrades silently when a connector’s schema isn’t reachable (no false positives).
  • skill_preflight shows, for the connector tools the skill actually calls, both their input schema and — once any approved run has dispatched the tool — its last-observed output shape (keys + types), so you know what -> R will bind before you run.
All three respect the connector’s allowed_tools gate: a tool the operator gated off is never surfaced or validated against. The observed-output-shape cache holds keys + types only (not values) and lives operator-local under ~/.skillscript/.

Identity propagation — for multi-agent hosts

Skip this section if your runtime serves one agent (CLI tools, single-user dashboards, hobby deployments). The default — SkillMeta.author captured from the SkillStore’s writer identity — already attributes authorship correctly when there’s only one writer. This section is for adopters whose runtime is fronted by an MCP host that bridges multiple authenticated agents into one transport (e.g., a multi-agent gateway, or a multi-tenant SaaS where agents share a runtime pool).

The gap MCP doesn’t close on its own

JSON-RPC over HTTP doesn’t carry a standard “calling identity” field. Without an extra convention, every skill_write call into your runtime stamps SkillMeta.author = <runtime's own writer identity> — regardless of which agent on the host actually originated the call. Subsequent execute_skill dispatches then run under the wrong scope. Identity-scoped reads return the runtime’s own data, not the calling agent’s.

Opt-in: a configurable inbound header

When you configure dashboard.mcpCallerIdentityHeader, the runtime reads that header on every /rpc request and threads its value as the caller-identity through to skill_write. The handler stamps SkillMeta.author = <header value>. Different callers with different header values get distinct stored authors.
{
  "dashboard": {
    "host": "127.0.0.1",
    "port": 7878,
    "mcpCallerIdentityHeader": "X-Agent-Id"
  }
}
Multi-agent host (custom MCP gateway, etc.) is responsible for setting the header on every outbound request:
POST /rpc HTTP/1.1
Host: skillscript-runtime
Content-Type: application/json
X-Agent-Id: alice

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"skill_write","arguments":{"name":"alice-skill","source":"..."}}}
  • Header lookup is case-insensitive (Node lowercases inbound header names).
  • Absent header on a configured runtime → caller identity is undefined for that request → SkillStore.store() falls back to its default author capture (existing single-tenant behavior). Backwards-compatible — hosts that don’t inject identity behave exactly as before.
  • Empty header value → treated as absent.

Trust model

The runtime trusts the host’s header attestation. There’s no signature verification on the identity claim (distinct from secured-mode approval, which is signature-verified — different boundary, different mechanism): anyone reaching the runtime with a forged X-Agent-Id could claim to be anyone. The runtime is not the authentication boundary; the host is. Bilateral trust:
  • The host (your MCP gateway) authenticates the agent via its own auth surface (OAuth, JWT, session cookies, mTLS — whatever fits your platform) and injects the verified identity into the outbound X-Agent-Id header.
  • The runtime trusts the host because you configured it to (mcpCallerIdentityHeader is opt-in; unset means “I don’t trust any inbound identity claim, fall back to my own writer identity”).
Don’t expose the /rpc endpoint directly to untrusted clients with this configuration. Run behind your host’s auth-enforcing reverse proxy or in a trusted-network deployment.

Inbound vs outbound — same header, two layers

Connectors like HttpMcpConnector use the same header name (X-Agent-Id by convention) for outbound calls to substrates — see the HttpMcpConnector configuration above. The two are NOT the same value in general:
  • Inbound (this section) = request-scoped caller — who’s currently calling the runtime via MCP.
  • Outbound (HttpMcpConnector.identityHeader) = dispatch-scoped owner — derived from SkillMeta.author of the skill being executed, asserted to the substrate so reads land in the owner’s scope.
They MEET at SkillMeta.author. The runtime captures inbound caller identity at skill_write (stamps it as the skill’s author); at execute time, the runtime threads author into ctx.agentId; the outbound connector asserts that to the substrate. The same X-Agent-Id header carries two different identity claims at the two boundaries; the stored author is the bridge. Critical: never forward an inbound X-Agent-Id header straight to an outbound connector. The skill’s owner is who should access the substrate, not the current caller. If anyone invokes alice’s skill and the outbound used the caller’s identity instead of alice’s, the substrate would scope to the caller — a setuid hazard. The runtime keeps the two separate; outbound identity is always derived from author at dispatch.

Verification

After wiring + restart, a smoke test:
# Write a skill as alice
curl -X POST http://localhost:7878/rpc \
  -H "content-type: application/json" \
  -H "X-Agent-Id: alice" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"skill_write","arguments":{"name":"smoke","source":"# Skill: smoke\n# Status: Draft\nrun:\n    emit(text=\"hi\")\ndefault: run"}}}'

# Verify author was stamped from the header
curl -X POST http://localhost:7878/rpc \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"skill_preflight","arguments":{"name":"smoke"}}}' \
  | jq '.result.content[0].text | fromjson | .metadata.author'
# Expected: "alice"
If the second call returns the runtime’s own writer identity instead of "alice", either the config field is unset, the header didn’t reach the runtime (check your proxy / host wiring), or you sent the request with a different header name than configured.

Conventions for upstream-merge-friendly modifications

If your wiring needs require modifying skillscript-runtime source (rather than just configuration), follow these conventions to minimize merge friction.

1. Prefer dedicated adopter files over editing upstream

Put your code in dedicated paths upstream won’t touch:
src/connectors/local/my-data-store-adapter.ts    ← adopter-owned
src/connectors/local/my-llm-adapter.ts           ← adopter-owned
Upstream changes to src/connectors/data-store.ts won’t conflict with your local/ files.

2. Use the public registration API; don’t edit the closed-set Map

KNOWN_CONNECTOR_CLASSES in src/connectors/config.ts is upstream-owned. Add your classes via registerConnectorClass(name, entry) from your bootstrap instead. Closes the merge-conflict bait of editing that file every release.

3. Mark unavoidable upstream-file edits with sentinels

When you genuinely have to edit an upstream file, mark the change:
// ADOPTER:myorg — extend dispatch to call our auditor before forward
if (process.env["MYORG_AUDIT"] === "1") { /* ... */ }
The // ADOPTER:myorg — prefix is greppable across merges; your future-self can re-evaluate whether the modification is still needed when upstream changes the surrounding code.

4. Treat src/bootstrap.ts as reference, not canonical

For the standard “wire the whole runtime + dashboard from $SKILLSCRIPT_HOME like the CLI does” case — which is most adopters — call bootstrapFromEnv() (see §“Programmatic bootstrap path”). It’s the supported public entry point; you don’t hand-assemble anything, and you swap substrates declaratively via connectors.json. Remember to await wired.registry.disposeAll() on shutdown so stdio-bridged connector children (RemoteMcpConnector) are reaped, not orphaned — bootstrapFromEnv() callers own teardown (the CLI does it for you). Drop to raw bootstrap() + Registry only when you’re hand-assembling a substrate that connectors.json can’t express — import the public APIs (Registry, the connector classes, loadConnectorsConfig, loadSkillscriptConfig, etc.) rather than modifying the bundled bootstrap, which churns on every upstream release. See examples/custom-bootstrap.example.ts for a worked walkthrough.

Substrate ship-status

SubstrateShipped contractShipped implsShipped bridge
SkillStore✓ 8 methods (load / query / store / update_status / delete / versions / metadata / staticCapabilities)FilesystemSkillStore, SqliteSkillStoren/a
DataStore✓ 3 methods (query / write / get)SqliteDataStoreDataStoreMcpConnector
LocalModel✓ 1 method (run)OllamaLocalModelLocalModelMcpConnector
McpConnector✓ 1 method (call)RemoteMcpConnector, CallbackMcpConnectorn/a
AgentConnector✓ 5 required (list_agents / deliver / wake / health_check / request_response) + 1 optional (agent_status)NoOpAgentConnector (default), HttpWebhookAgentConnectorn/a
Notable things the playbook should be honest about:
  • SqliteDataStore is a deliberately minimal reference impl. It satisfies the contract (query / write / get / staticCapabilities / manifest) with FTS-style tag/text retrieval. It does NOT support semantic retrieval, pinning, decay scoring, or thread-status filtering (the relevant supports_* flags are all false). Deployments that need richer query semantics fork examples/connectors/DataStoreTemplate/ and wire their backing substrate.
  • SkillStore and DataStore have different lifecycle models — by design. SkillStore is mutable / versioned / named CRUD (Draft→Approved→Disabled→Delete with audit trail). DataStore is append-only with query/get (no per-record lifecycle in the contract). If you back both onto one substrate, you’re serving both lifecycle models at once. Substrates that conflate “data record expiry” with “skill expiry” silently break authored code; the contract doesn’t enforce this, you handle it impl-side.
  • Durability is implementer’s responsibility. The typed contracts assume durable storage. Neither interface declares “writes live forever” — but the runtime + lint + dashboard all behave as if writes persist indefinitely. If your substrate has GC / TTL / decay scoring, build adopter-side guards (pin-rules, retention policies, periodic re-pin sweeps) or pick a substrate posture that satisfies “durable forever.” Silent staleness is the failure mode the contract won’t catch.
  • Mutation ops require runtime-enforced authorization. $ data_write / file_write / $ <mutating-name-tool> (write/update/delete/etc.) fire UnconfirmedMutationError at the runtime boundary unless the skill carries # Autonomous: true (cron/agent-fired) OR a preceding ?? / ask(...) confirms in the same target OR the op carries approved="reason" per-op kwarg. This fires regardless of how the skill was invoked — execute_skill({name}) AND execute_skill({source}) honor the gate identically; lint stays advisory. Adopters running unattended skills programmatically should set # Autonomous: true at the header.
  • In-skill writes have asymmetric trust models. $ skill_write lands its child as # Status: Draft regardless of body declaration — the bridge forces it. Authoring an executable artifact has unbounded blast radius (the child fires arbitrarily many times in arbitrary contexts); the Draft default keeps autonomously-written skills out of the immediate execution loop. $ data_write writes verbatim — one bad data row is bounded blast radius. SkillStore impls receive the body already Draft-stamped; DataStore impls receive entries as authored.
  • Your DataStore.write() is never called if the mutation gate rejects the skill. The runtime gates $ data_write (and other mutation ops) upstream of the bridge — substrates only see authorized writes. If your own probes hit UnconfirmedMutationError, that’s a skill-body issue (missing # Autonomous: true / ?? / approved=), not a substrate issue.
  • Filter scope is enforced at the bridge. DataStoreMcpConnector rejects every filter key outside the substrate’s declared manifest().supported_filters set, throwing UnsupportedFilterError. This prevents silent scope leaks where unsupported filters get dropped without the caller knowing. Per-call opt-out: permissive_filters: true acknowledges “unknown keys are advisory; substrate may ignore them.” Substrate implementors: declare every filter your query() actually honors so the bridge validates against your truth, not a guess.
  • FTS matching strictness varies by substrate. The DataStore.query() contract names the modes (fts / semantic / rerank) but doesn’t pin down matching semantics within each mode — token-OR, phrase-tokens, fuzzy, exact, FTS5-syntax-passthrough, etc. are all conformant. The bundled data-store-roundtrip demo asserts N ≥ 1 (a successful round-trip) rather than a specific count, which works across any FTS-supporting substrate. For adopters who need deterministic exact-count reads (round-trip tests, idempotency checks, exact-record-matched fetches), the portable strict-match path is domain_tags=[...] filtering — the bridge enforces tag-key against supported_filters and substrates declaring supports_tag_filter: true honor exact-tag any-of-match per the contract. Use FTS for relevance ranking against open content; use tag filters when you need to be sure you got the specific record you wrote.
  • Durable-forever opt-in via expires_at: null. DataWrite.expires_at accepts a unix timestamp for finite expiry, null to opt into “durable forever” (the portable verb for substrates with default TTL — AMP memory vaults, Redis with default expiry, hosted memory APIs), or omitted (substrate’s default lifecycle, may be durable or may have decay). Substrates that are durable-by-default (the bundled SqliteDataStore) treat null as a no-op. Substrates with default sweep should map null to their pin / no-decay flag.
  • Two trigger primitives, both functional. cron (time-based) and event (HTTP /event ingress, named registration). All other concepts that look like triggers — session-start, agent-event, file-watch, sensor — are adapter responsibilities: the adapter POSTs /event when relevant. Keeps the runtime substrate-neutral; the trigger surface stays tight.
  • Output kinds are intentionally substrate-neutral. # Output: accepts text / agent: <name> / template: <name> / file: <path> / none. Substrate-specific values (slack:, card:, etc.) are out of scope — adopters wanting Slack / WhatsApp / Discord / etc. delivery use either $ slack.post ... MCP dispatch inside the skill body OR deliver via agent: <name> and let the receiving agent decide.
  • Authorization is signature-based in secured mode. An Approved skill runs unkeyed in unsecured mode (a bare # Status: Approved is sufficient) and requires a valid Ed25519 operator signature in secured mode — verified on every execution against the operator’s public key, with the private key held off the runtime. See Approval + secured mode for the model, the approve flow, key custody, and migration. The shell + filesystem allowlists bound blast radius in both modes; secured mode is what gates whether an unapproved skill runs at all.

Skill discovery + cross-agent composition

If you back your SkillStore against a substrate that ALSO holds general data records (one substrate serving both contracts), skill discovery can use the canonical $ data_read surface to find skills via tag/query:
$ data_read mode=fts query="incident triage" limit=5 -> SKILLS
foreach S in ${SKILLS.items}:
    execute_skill(skill_name="${S.name}", ...) -> RESULT
This works only when the data substrate is Case-1 wired (typed-contract via bridge) AND your substrate’s records identify skills somehow (a tag, a payload-type marker, etc. — your impl’s choice). Under Case-2 wiring, you’d need substrate-specific tool calls which are non-portable. For most deployments, skill discovery goes through the canonical skill_list MCP tool (which calls SkillStore.query()). The $ data_read-as-discovery pattern is for the niche case where skills and other records share a backing store with rich tag/query semantics.

Contributing — dispatch-shape discipline

The multi-layer-promise pattern (lint passes; runtime fails, or vice versa) is the recurring failure mode for dispatch-shape work. validateQualifiedDispatch is the shared validator lint and runtime both call. To prevent the next recurrence, every PR that introduces a new dispatch shape (a new way of writing $ ... ops, a new connector class entry point, a new lifecycle hook on # Output:) must land with:
  1. Lint test — fixture that exercises the shape with lint only (lint(source, {registry}))
  2. Runtime test — same shape executed end-to-end (executeSkillByName or executeSkillFromSource)
  3. E2E test — the full user path (write skill → store → execute via MCP, or trigger fire → dispatch)
PR description must call out which dispatch shape is exercised. If you can’t write all three for a shape, that’s a signal the shape is incompletely specified — file a thread before merging. Connector class authors implementing new McpConnectorClass-shaped contracts should also implement staticTools(): string[] | null whenever the tool surface is closed and knowable at compile time. Lift unknown-tool-on-connector from “advisory you fix at runtime” to “tier-1 error caught at compile time” for every adopter who wires your class.

Resources

  • Onboarding scaffoldexamples/onboarding-scaffold/ — complete adopter deployment with a file-backed data store + OpenAI + tmux
  • Custom bootstrap walkthroughexamples/custom-bootstrap.example.ts — registering custom MCP connector classes
  • Connectors examplescaffold/connectors.json — annotated connectors.json shape
  • Language referencedocs/language-reference.md — skill syntax + frontmatter + lint codes
  • Connector contractsdocs/connector-contract-reference.md — substrate-neutral contract surfaces
  • Configurationdocs/configuration.mdconnectors.json shape + substrate selection