Skip to main content
How to configure a skillscript-runtime deployment. The single config file is ~/.skillscript/connectors.json (or any path passed via --connectors). It has two top-level concerns:
  1. substrate — which SkillStore, DataStore, LocalModel, and AgentConnector the runtime hosts (MCP server + web dashboard) use.
  2. Named MCP connector instancesyoutrack, github, etc. — invoked via $ <name> in skill source.
The runtime loads connectors.json at startup. Missing file → graceful empty config (substrate defaults to filesystem skills + conditional sqlite memories; no MCP connectors). Malformed JSON or unknown fields → structured errors surfaced at bootstrap.

SKILLSCRIPT_HOME — the root override

Every default path the runtime computes is rooted under SKILLSCRIPT_HOME:
Default pathResolves to
Connectors file$SKILLSCRIPT_HOME/connectors.json
Config file$SKILLSCRIPT_HOME/skillscript.config.json
Triggers file$SKILLSCRIPT_HOME/triggers.json
Skills directory (skillsDir)$SKILLSCRIPT_HOME/skills/
Sqlite skill store dbPath$SKILLSCRIPT_HOME/skills/skills.db
Sqlite data store dbPath$SKILLSCRIPT_HOME/data.db
Trace directory$SKILLSCRIPT_HOME/traces/
SKILLSCRIPT_HOME defaults to ~/.skillscript. Set the env var to relocate everything under a different root — the cleanest multi-instance isolation primitive:
# Adopter instance with fully isolated state
export SKILLSCRIPT_HOME=~/.skillscript-adopter
skillfile dashboard --host 127.0.0.1 --port 7879
Every derived path now lives under ~/.skillscript-adopter/; the dev instance at ~/.skillscript/ is untouched. No --connectors flag, no explicit dbPath overrides needed — defaults follow SKILLSCRIPT_HOME. See docs/adopter-playbook.md § “Two-instance posture” for the broader pattern.
Why this matters for adopter setups. Without SKILLSCRIPT_HOME isolation, two daemons running side-by-side would share triggers.json, skillsDir (filesystem default), and any other $HOME/<thing> default — even if their sqlite dbPaths were explicitly distinct. SKILLSCRIPT_HOME is the architectural primitive; everything else derives from it.

Environment variables + .env file

The CLI auto-loads $SKILLSCRIPT_HOME/.env at startup and populates process.env for any key not already set in the shell. Drop a .env next to skillscript.config.json and posture switches are picked up at next restart — the installer/operator pattern.

Direct env-var reads — the runtime checks process.env for these

Env varEffectCascade precedence
SKILLSCRIPT_HOMEConfig root (default ~/.skillscript)shell-set only — read before .env loads
SKILLSCRIPT_FORCE_ALWAYS_DRAFT=trueForce outside-MCP skill_write to Draft; closes the agent-self-approval pathenv > config > default false
SKILLSCRIPT_ENABLE_UNSAFE_SHELL=truePermit shell(unsafe=true) ops (syntax-scope axis)env > config > default false
SKILLSCRIPT_SHELL_ALLOWLIST=curl,git,jqComma-separated list of binaries reachable via shell(...) ops (binary-scope axis). Default-deny when unset — run skillfile shell-audit to discover your corpus’s set. Honored on both CLI and programmatic-bootstrap() paths; explicit bootstrap({ shellAllowlist: [...] }) (including [] deny-all) wins over env. See adopter-playbook § “Programmatic bootstrap path” for the precedence table.bootstrap() opt > env > config > default-deny
SKILLSCRIPT_FS_ALLOWLIST=/srv/work,/var/eventsComma-separated roots under which file_read / file_write may operate (path-scope axis). Default-deny when unset — every file op refused. Canonicalized before the check, so .. / symlink escapes are closed. Keep secret / key directories OUT.env > config > default-deny
SKILLSCRIPT_SECURED_MODE=trueEnforce the approval boundary: only Ed25519-signed skills perform effectful ops; unapproved or tampered skills are refused regardless of dispatch path (CLI / cron / /event / MCP / composition). Default false — a bare # Status: Approved is sufficient (unkeyed). See adopter-playbook § “Approval + secured mode”.bootstrap() opt > env > config > default false
SKILLSCRIPT_APPROVAL_KEY_FILE=<path>Operator private signing key — read only by the approve flow, never on the execution hot path. Default ~/.config/skillscript/approval.key (deliberately outside SKILLSCRIPT_HOME). Auto-provisioned 0600 on first secured-mode start if absent.env > default
SKILLSCRIPT_APPROVAL_PUBLIC_KEY_FILE=<path>Operator public verification key (non-secret) — read by the runtime to verify signatures on every execution. Default ~/.config/skillscript/approval.pub.env > default
SKILLSCRIPT_APPROVAL_PASSCODE=<passcode>Opt into in-browser dashboard approval: when set, the dashboard signs server-side after a one-time passcode unlock (session-scoped, ~15-min idle TTL). Unset = dashboard is review-only; signing only via skillfile approve at a terminal.env > default unset
SKILLSCRIPT_DASHBOARD_AUTH_TOKEN=<token>Gate the dashboard SPA + /rpc behind a token (?token= / cookie / Authorization: Bearer). Network hygiene for a dashboard reachable beyond localhost — not a forgery boundary. (/event keeps its own bearer gate.)env > default unset
SKILLSCRIPT_PORT=8080Dashboard / serve HTTP port--port flag > env > config > default 7878
SKILLSCRIPT_HOST=0.0.0.0Bind address--host flag > env > config > default 127.0.0.1
SKILLSCRIPT_MCP_CALLER_IDENTITY_HEADER=X-Agent-IdInbound caller-identity header name (multi-agent MCP hosts only — see adopter playbook)env > config > default unset
SKILLSCRIPT_POLL_INTERVAL_SECONDS=30Scheduler tick / poll interval (seconds)env > config > default 30
SKILLSCRIPT_ABSOLUTE_TIMEOUT_MS=300000Runtime fallback timeout (ms) when no per-op / skill / connector default appliesenv > config > default 300000 (5 min)
SKILLSCRIPT_MAX_RECURSION_DEPTH=10Composition recursion depth ceiling for $ execute_skill chainsenv > config > default 10
SKILLSCRIPT_EVENT_INGRESS_ENABLED=trueMount POST /event for event-triggered skills. Default false — route returns 404 when not enabled. Shares SKILLSCRIPT_PORT with dashboard/RPC (one HTTP server).env > default false
SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN=<token>Bearer-token auth for POST /event. When set, every event POST requires Authorization: Bearer <token>; 401 otherwise. Default unset = open-internally (still gated by bind address).env > default unset
OLLAMA_BASE_URL=http://...Ollama endpoint for LocalModel (default http://localhost:11434)env > built-in default
SKILLSCRIPT_HOME is the chicken-and-egg case — the path to .env requires it, so .env can’t set it. Use shell, Docker -e, or systemd Environment= instead.

Indirect via ${VAR} substitution in config files

Once .env populates process.env, both skillscript.config.json (runtime-config.ts) and connectors.json (connectors/config.ts) resolve ${VAR} references in string values at load time. So a .env-set var flows into:
  • skillscript.config.json — any string field. Examples: dashboard.host: "${BIND_HOST}", triggersFilePath: "${TRIGGERS_PATH}".
  • connectors.json — the big one for adopter wiring. Endpoints, auth tokens, child-process env blocks. Example:
    {
      "amp": {
        "class": "HttpMcpConnector",
        "config": {
          "endpoint": "${AMP_ENDPOINT}",
          "headers": { "Authorization": "Bearer ${AMP_TOKEN}" },
          "identityHeader": "X-Agent-Id"
        }
      }
    }
    
    AMP_ENDPOINT and AMP_TOKEN in .env; declarative shape committed to connectors.json.

.env file format

Standard dotenv conventions:
# comment lines start with #
KEY=value
KEY_WITH_SPACES="value with spaces"
URL=https://example.com/path?key=value
Supported: KEY=value, quoted strings (double + single), # comment lines, blank lines, embedded equals signs in values. Rejected (logged as warnings, skipped): malformed entries without =, invalid key names. Missing file → no-op. NOT supported (deliberately — use JSON config or shell-escape for these): multi-line values, variable interpolation within values (${OTHER_VAR} inside a value), export KEY=value prefix, inline comments after a value.

Precedence summary (most-specific wins)

  1. CLI flag (e.g., --port 8080)
  2. Shell-set env var (export SKILLSCRIPT_PORT=8080)
  3. .env file in $SKILLSCRIPT_HOME
  4. skillscript.config.json field
  5. Built-in default

skillfile init seeds .env.example

Running skillfile init writes $SKILLSCRIPT_HOME/.env.example documenting every recognized env var. Operators copy to .env and edit. Re-running init never overwrites operator-edited .env — only writes the template.

Adopter credential discipline

  • Commit connectors.json with ${VAR} references; never literal secrets.
  • .gitignore .env (the file with real values); commit .env.example (the template).
  • See Credential discipline below for the broader pattern.

Quick start

A typical out-of-the-box ~/.skillscript/connectors.json:
{
  "substrate": {
    "skill_store": "filesystem",
    "data_store": "sqlite",
    "local_model": null,
    "agent_connector": null
  }
}
Equivalent to omitting the file entirely — these are the base config defaults. agent_connector: null falls back to the silent NoOpAgentConnector — skills with # Output: agent: X complete cleanly with a stderr warning; replace with "noop" for the same behavior stated explicitly, or with a "custom" entry to wire an adopter impl. To switch skills storage to SQLite:
{
  "substrate": {
    "skill_store": "sqlite"
  }
}
Restart skillfile dashboard (or skillfile serve). The MCP server + dashboard UI now read/write skills from ~/.skillscript/skills/skills.db instead of .skill.md files.
Heads up on startup logs. Sqlite-backed substrates use the built-in node:sqlite module, which is still flagged experimental in Node 22. Expect this line on every launch until Node de-experimentalizes it: ExperimentalWarning: SQLite is an experimental feature and might change at any time. Harmless; can be silenced per-process with NODE_OPTIONS="--disable-warning=ExperimentalWarning" if it clutters your logs.

The substrate section

Singleton substrate connectors. Each slot accepts one of four shapes:

Short form — bare string

"skill_store": "sqlite"
Wires the bundled implementation for that type with default config (e.g., dbPath under ~/.skillscript/). Valid short-form values per slot:
SlotValues
skill_store"filesystem" | "sqlite"
data_store"sqlite"
local_model(none — "ollama" requires the object form with defaultModelTag; see below)
agent_connector"noop" (explicit silent fallback; same behavior as null)

Null — explicit “no substrate”

"local_model": null,
"agent_connector": null
The runtime doesn’t register a real connector for this slot. local_model: null leaves $ llm un-wired (skills calling it error at execute time). agent_connector: null falls back to NoOpAgentConnector# Output: agent: declarations complete with a stderr warning instead of throwing, so a runtime can start without any agent harness wired.

Object form — override defaults

"skill_store": {
  "type": "sqlite",
  "config": {
    "dbPath": "/var/skillscript/skills.db"
  }
}
type picks the bundled impl; config is passed to its constructor. Per-type config fields:
TypeConfig fields
filesystem (skill_store)none — uses the CLI’s skillsDir (defaults to $SKILLSCRIPT_HOME/skills/)
sqlite (skill_store)dbPath (default: $SKILLSCRIPT_HOME/skills/skills.db)
sqlite (data_store)dbPath (default: $SKILLSCRIPT_HOME/data.db; DATA_DB env overrides)
ollama (local_model)baseUrl (default: OLLAMA_BASE_URL env or http://localhost:11434), defaultModelTag (required — e.g., "gemma2:9b", "llama3.1:8b")
noop (agent_connector)none — silent fallback (warn + resolve). Real adopter impls use the custom form below.
SqliteDataStore feature surface. The bundled sqlite data_store is a deliberately minimal reference implementation: supports_writes + supports_tag_filter are true; supports_semantic, supports_pinning, supports_decay_model, supports_thread_status_filter are all false. Rich features (semantic retrieval, pinning, decay scoring, thread-status workflow) come from substrate impls — adopters fork examples/connectors/DataStoreTemplate/ and wire their backing system (memory broker, vector DB, AMP, etc.). The bundled impl exists so the runtime works out-of-box; adopters with richer query semantics write their own.
Worked Ollama example (because the short form isn’t valid for local_model):
{
  "substrate": {
    "local_model": {
      "type": "ollama",
      "config": {
        "defaultModelTag": "gemma2:9b"
      }
    }
  }
}
Pin the model tag explicitly — must be a tag your Ollama instance has pulled (ollama pull gemma2:9b). Bare "local_model": "ollama" errors out at bootstrap because the model name is too important to silently default.

Custom form — adopter-written impl

"skill_store": {
  "type": "custom",
  "module": "./my-skill-store.js",
  "export": "MySkillStore",
  "config": {
    "vault": "team"
  }
}
References an adopter-written class implementing the relevant contract. module is the path to the JS file; export is the named export (defaults to default); config is passed to the constructor. The same shape works for any substrate slot:
"agent_connector": {
  "type": "custom",
  "module": "./my-agent-connector.js",
  "export": "MyHttpWebhookAgentConnector",
  "config": {
    "endpoint": "${AGENT_ENDPOINT}"
  }
}
Limitation: sync bootstrap() can’t dynamic-import. Custom-via-connectors.json surfaces a clear error and falls back to the default. Adopters wanting custom impls today write a programmatic bootstrap that calls registry.registerSkillStore("primary", new MySkillStore(...)) (or registry.registerAgentConnector(...)) directly — same pattern as the runtime’s reference bootstrap(). Async-bootstrap with dynamic-import support is planned.

Precedence

When multiple config sources speak:
  1. Programmatic opts (opts.skillStore / opts.dataStore / opts.localModel / opts.agentConnector passed to bootstrap()) — explicit, highest priority
  2. connectors.json substrate section — declarative, deployment-durable
  3. Built-in default — fallback (filesystem skill_store; conditional sqlite data_store; no local_model; NoOpAgentConnector)
If two configs disagree, the higher-priority one wins; lower-priority is ignored without error.

Which surfaces honor substrate config?

SurfaceHonors substrate?Reasoning
MCP server (skillfile serve, dashboard /rpc)MCP is the agent-facing surface; must read/write whichever store the deployment chose
Web dashboard (skillfile dashboard)Same as MCP — agents and humans connect to the same runtime
Programmatic embed (your own bootstrap)You pass opts.skillStore directly; the runtime takes whatever
skillfile compile✗ filesystem-onlyAuthoring loop: vim foo.skill.md && skillfile compile foo. Only coherent against FS.
skillfile lint✗ filesystem-onlySame as compile.
skillfile audit✗ filesystem-onlyOperates on a provenance file + the FS-authored source.
skillfile list✗ filesystem-onlyFilesystem listing of .skill.md files.
The four authoring CLI commands stay FS-pinned by design — they’re the filesystem-first authoring loop. Sqlite-backed skills are authored via the dashboard UI or the skill_write MCP tool, not these CLI commands.

Named MCP connector instances

Per-host MCP connector wiring. Each top-level key (other than substrate) defines a named connector referenced via $ <name> in skill source.
{
  "substrate": { "skill_store": "sqlite" },

  "youtrack": {
    "class": "RemoteMcpConnector",
    "config": {
      "command": "npx",
      "args": ["mcp-remote", "https://example.youtrack.cloud/mcp"],
      "framing": "newline",
      "env": { "AUTH_HEADER": "Bearer ${YOUTRACK_TOKEN}" }
    },
    "allowed_tools": ["list_issues", "get_issue", "create_comment"]
  },

  "github": {
    "class": "RemoteMcpConnector",
    "config": { /* ... */ },
    "allowed_tools": ["search_repos", "get_issue"]
  }
}
Each entry needs:
  • class — a class from the closed-set registry. Today: RemoteMcpConnector (stdio-bridged remote MCP). Adopters can register custom classes via registerConnectorClass() from their bootstrap.
  • config — passed to the class’s fromConfig() factory. Schema is class-specific.
  • allowed_tools (optional) — per-connector tool allowlist at the entry top-level (sibling to class / config, NOT inside config). undefined = allow all; [] = allow none; listed array = exactly those. Placing allowed_tools inside the config: block is a hard parse error — the loader refuses to load to prevent a silent allow-all bypass (a security control quietly doing nothing on misplacement is the worst-case failure mode).

RemoteMcpConnector config — stdio framing

RemoteMcpConnector speaks JSON-RPC over the spawned child’s stdio. Two framing conventions are supported via the framing config key:
  • "newline" — one JSON-RPC message per line, newline-delimited. This is what mcp-remote (the npm package) and most spec-compliant MCP stdio servers use. Recommended for almost all adopters.
  • "lsp" (legacy default) — Content-Length: N\r\n\r\n<body> per the LSP convention. Only set this if your specific MCP server explicitly uses LSP-style framing.
With the wrong framing, the connector hangs to init_timeout because the child can’t parse the request. Set framing explicitly in your connector config to avoid the silent hang — the init-timeout error message names framing as a likely cause, but the explicit setting is the durable fix.

Credential discipline

connectors.json is secret-bearing. The repo .gitignore excludes it by default; connectors.json.example (not real values) is committed as a template. For deployments, prefer ${VAR} env-var substitution over literals — commit the ${...} references; keep secrets in deployment environment. Skillscript warns at bootstrap if connectors.json lives in a git-tracked directory without a .gitignore entry.

${VAR} substitution

"env": { "AUTH_HEADER": "Bearer ${YOUTRACK_TOKEN}" }
${NAME} resolves from process.env at load time. Missing env var → clear startup error (not silent empty string). The config.env block is itself resolved first, then merged into the substitution scope for the rest of the config — letting you compose values:
"config": {
  "env": { "AUTH_HEADER": "Bearer ${YOUTRACK_TOKEN}" },
  "args": ["--header", "Authorization:${AUTH_HEADER}"]
}
This matches the Claude Desktop mcp.json convention.

Inline comments

Underscore-prefixed top-level keys (_comment, _note_security, etc.) are ignored by the parser. Use them inline to document your config without external comments — the JSON spec doesn’t natively support comments, so the runtime treats _* keys as the convention.
{
  "_comment": "Last edited 2026-05-28 — switched skill_store to sqlite for AMP-style dogfooding",
  "substrate": { "skill_store": "sqlite" }
}

Adopter-custom substrate impls

Write class FooSkillStore implements SkillStore { ... } (or DataStore, LocalModel). Wire it via either: (a) Programmatic bootstrap (recommended today) — for the common case (wire everything from $SKILLSCRIPT_HOME like the CLI does), call bootstrapFromEnv() and declare your substrate in connectors.json; it loads .env + config + connectors.json, resolves the env cascade, and returns a fully-wired { wired, server } (both unstarted). Reach for the raw-Registry assembly below only when hand-constructing a substrate connectors.json can’t express:
import { Registry, McpServer, Scheduler } from "skillscript-runtime";
import { FooSkillStore } from "./foo-skill-store.js";

const registry = new Registry();
registry.registerSkillStore("primary", new FooSkillStore({ /* config */ }));
// ... register other substrates, then construct Scheduler + McpServer + DashboardServer
See docs/adopter-playbook.md §“Programmatic bootstrap path” for both — bootstrapFromEnv() (recommended) and the raw pattern. (b) connectors.json custom form (deferred to follow-up):
"skill_store": {
  "type": "custom",
  "module": "./foo-skill-store.js",
  "export": "FooSkillStore",
  "config": { ... }
}
Currently surfaces an error and falls back to the default — sync bootstrap() can’t dynamic-import. Track the async-bootstrap promotion as future work.

Operational tips

Switching substrates without losing data

The substrate switch is a runtime wiring change, not a data migration. Switching from filesystem to sqlite doesn’t move your .skill.md files into the Sqlite db automatically — the dashboard will show an empty skill list because the new Sqlite db is fresh. To preserve your skills across a switch:
  1. Read each .skill.md file from ~/.skillscript/skills/ (or your skillsDir)
  2. Call skill_write MCP tool (or store.store(name, source) programmatically) to land them in the new substrate
A bundled migration tool isn’t shipped — different adopters want different things (rename normalization, metadata enrichment, dry-run safety).

Multi-instance posture

Running both a dev instance (filesystem) and an adopter instance (sqlite or custom) side by side is common. Use separate --port + --connectors paths:
# Dev — filesystem skills, port 7878
skillfile dashboard --host 127.0.0.1 --port 7878

# Adopter — sqlite skills, port 7879
skillfile dashboard --host 127.0.0.1 --port 7879 --connectors ~/.skillscript/adopter-connectors.json
See docs/adopter-playbook.md § “Two-instance posture” for the broader pattern.

Verifying which substrate is wired

After a config change + restart, verify via runtime_capabilities:
curl -s -X POST http://localhost:7878/rpc \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"runtime_capabilities","arguments":{"include":["skillStores"]}}}' | jq
Output includes the wired SkillStore’s implementation field (FilesystemSkillStore or SqliteSkillStore).