Skip to content

Adding skills

Skills are self-contained directories under src/istota/skills/. Each skill needs a skill.md file with YAML frontmatter for metadata and a markdown body for documentation.

1. Create the skill directory

src/istota/skills/my_skill/
├── skill.md       # Frontmatter metadata + documentation (required)
├── __init__.py    # CLI module (optional)
└── __main__.py    # python -m support (optional)

2. Write skill.md with frontmatter

All metadata lives in the YAML frontmatter block. The markdown body is the documentation loaded into Claude's prompt.

---
name: my_skill
triggers: [my_keyword, another_keyword]
description: One-line description shown in the menu catalogue and `!skills`
cli: true
dependencies: [some-package]
env: [{"var":"MY_API_KEY","from":"secret","service":"my_service","key":"api_key","sensitive":true}]
---

# My Skill

Instructions for Claude on how to use this skill...

Use {BOT_NAME}, {BOT_DIR}, and {user_id} placeholders -- they're substituted at load time.

Frontmatter fields

Field Type Purpose
triggers list Documentation-only keywords surfaced by !skills; not a selector
description string Shown in the menu catalogue and !skills
always_include bool Load for every task
admin_only bool Hidden from non-admin users
cli bool Whether this skill has a CLI module
source_types list Auto-include for these task source types
file_types list Auto-include for these attachment extensions
companion_skills list Pull in these skills when this one is selected
exclude_skills list Remove these skills when this one is selected
dependencies list Python packages required (skip skill if missing)
requires_capability list Gate the skill on a runtime capability (browser, devbox) being configured
experimental bool Hide unless the operator enables the skill_<name> feature flag
exclude_memory bool Skip memory loading for tasks using this skill
exclude_persona bool Skip persona loading
env JSON array Declarative env var specs (see env var sources below)

Boolean fields default to false. List fields default to []. Only include fields that differ from defaults.

There is no name field. The directory name is the skill's identity — _loader.py takes name=skill_dir.name and never reads a name: key, so writing one has no effect and renaming a skill means renaming its directory.

3. (Optional) Create a CLI module

Skills can expose Python CLIs invoked by Claude via python -m istota.skills.my_skill:

# __init__.py
import argparse, json, sys

def build_parser():
    parser = argparse.ArgumentParser(description="My skill")
    sub = parser.add_subparsers(dest="command")
    cmd = sub.add_parser("my-command")
    cmd.add_argument("--flag")
    return parser

def cmd_my_command(args):
    result = {"status": "ok"}
    print(json.dumps(result))

def main():
    parser = build_parser()
    args = parser.parse_args()
    if args.command == "my-command":
        cmd_my_command(args)
    else:
        parser.print_help()
        sys.exit(1)

if __name__ == "__main__":
    main()
# __main__.py
from istota.skills.my_skill import main
main()

Pattern: build_parser() + main(), JSON output, credentials via env vars. See credentials for the two-tier model and how to wire new credentials into the proxy.

4. Declare env vars in the manifest

The skill's env: block is the only place env vars should be wired. The hardcoded credential-injection block in executor.py is gone; build_skill_env() walks every loaded skill's manifest and resolves each EnvSpec against the task's EnvContext. The derive_* helpers (see credentials) compute the proxy strip-set, auth map, and lookup allowlist directly from these manifests — no executor edits, no separate _PROXY_CREDENTIAL_VARS / _CREDENTIAL_SKILL_MAP to keep in sync.

env:
  - {"var":"MY_API_KEY","from":"secret","service":"my_service","key":"api_key","sensitive":true}
  - {"var":"MY_API_HOST","from":"config","config_path":"my_section.api_host","when":"my_section.enabled"}

For complex setups that need to compute values, write helper scripts, or bind-mount files into the sandbox (see developer for a worked example), export setup_env(ctx) -> dict[str, str] in the skill's __init__.py and use from: "setup_env" for the corresponding var. The hook fires for the full index regardless of selection, so the skill's helper scripts work even when the skill itself isn't keyword-matched.

5. (Optional) Read workspace files

If your skill reads user files (notes, todos, reminders), read them by workspace convention under the user's bot dir ({BOT_DIR}/notes/) via the files skill's mount-aware read_text. There is no longer a declarable resource type for these — the todo_file / reminders_file / notes_folder types were retired by the Resources sunset. Out-of-workspace folder mounts are the only remaining declarable resource (folder), provisioned by an operator via istota resource ensure.

Env var sources

from: Purpose
config Dotted config path. Use when: (string or list) to gate on truthy paths
secret Per-user encrypted secret (service + key from the secrets table)
setup_env Value computed by the skill's setup_env(ctx) hook in __init__.py
template_file Auto-create file from a template
user_id Literal task user_id

EnvSpec flags:

Flag Meaning
sensitive: true Treat as a credential — strip from Claude's env, route through the proxy. Auto-enrolls the skill in derive_credential_set / derive_skill_credential_map
proxy_only: true Withhold from Claude's env and hand to the proxy, without credential semantics: no auto-authorization, no credential-fetch lookup, no per-skill scoping. For non-secret values the model still must not hold — today, paths to SQLite files (HEALTH_DB_PATH, LOCATION_DB_PATH). Mutually exclusive with sensitive; the credential split runs first, so a spec marked both routes as a credential
fallback_var: "FOO" Read os.environ["FOO"] if primary resolution fails. Honored on the value path only — auto-authorization disables fallbacks so an instance-wide EnvironmentFile value can't fan out to per-user auth
gate_has_discovered_calendars: true Only resolve when CalDAV discovery returned at least one calendar