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()
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 |