Help the user create a new terva extension. An extension is an executable plugin in any language. It adds a slash command, a tool for the model, or a guard hook. Do not use this skill to author a `SKILL.md` skill. For that, use write-terva-skill.
Installs into .claude/skills of the current project.
Are you the author of Write Terva Extension?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/terva-sh-write-terva-extension)
---
name: write-terva-extension
description: Help the user create a new terva extension. An extension is an executable plugin in any language. It adds a slash command, a tool for the model, or a guard hook. Do not use this skill to author a `SKILL.md` skill. For that, use write-terva-skill.
---
# Writing a terva extension
Use this skill when the user asks for help building a terva extension —
a new slash command, a new tool the LLM can call, an audit hook, or
a permission gate. Skim this whole skill first, then collaborate
with the user on the specific extension they want.
## What an extension is
A terva extension is **an external executable** that terva launches as a
subprocess and talks to over its stdin/stdout in newline-delimited
JSON. It can be written in any language that can read/write JSON
lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with
jq, anything. Crash isolation is automatic; one bad extension never
takes down terva.
Three things an extension can do (any combination):
1. **Slash commands** — register `/foo` so the user can run it from
the input. The handler returns a "prompt" (submitted to the
agent), an "insert" (text dropped into the editor), a "display"
(one-shot styled note in the chat), or a "noop".
2. **Tools** — register tools the LLM itself calls. Schema is
JSON Schema; terva routes the model's `tool_call` to the
extension's `tool_result`. Same lifecycle as built-in tools
(read/write/edit/bash/skill).
3. **Lifecycle hooks** — subscribe to events
(session_start, turn_start, tool_call, turn_end,
assistant_message) for telemetry / audit / custom UI, or
intercept tool calls before execution to refuse dangerous
patterns.
4. **Model context & tool visibility** — fold standing guidance into the
system prompt (`register_context` / `refresh_context`) and hide tools
that can't do anything useful in the current workspace
(`set_withdrawn_tools`). These touch the model's *cached* prefix, so
they carry a discipline — see "Being responsible with context & tools"
below before reaching for them.
## On-disk layout
Each extension lives in its own directory:
```
~/Library/Application Support/terva/extensions/<name>/
├── extension.json # manifest (required)
└── <executable> # whatever exec points at
```
Or project-local: `<project>/.terva/extensions/<name>/`. Project-local
wins on name conflict.
For ad-hoc use during development, skip the install step entirely
and run `terva --ext PATH` (repeatable: `-e PATH -e PATH`).
### Manifest
```json
{
"name": "weather",
"version": "1.0.0",
"exec": "./weather",
"args": [],
"language": "go",
"description": "current weather lookups for any city",
"enabled": true
}
```
Field rules:
- `name` (required, unique) — id terva uses internally; matches the
hello frame. Slash commands & tools live in the same name space
as built-ins; conflicts are silently shadowed by built-ins.
- `exec` (required) — the executable path. Resolution:
- absolute: as-is
- starts with `./` or `../`: relative to the manifest's directory
- bare name (no separator): looked up via `$PATH` (e.g. `node`,
`python3`, `npx`, `tsx`)
- `args` — extra argv passed to `exec` (e.g. `["index.js"]`)
- `language` — informational only (`"go"`, `"typescript"`,
`"python"`, etc.)
- `enabled` — defaults to true; set false to keep installed but skip
## Wire format
Newline-delimited JSON in both directions. Top-level `type` is the
discriminator. Optional `id` correlates command/tool requests with
their responses.
### Required handshake
The very first frame the extension sends is `hello`:
```json
{"type":"hello","name":"weather","version":"1.0.0",
"capabilities":["commands","tools"]}
```
Capabilities are advisory; current values are `commands`, `tools`,
`events`. Send all that apply.
terva replies with `hello_ack`:
```json
{"type":"hello_ack","protocol_version":4,"terva_version":"0.0.x",
"provider":"anthropic","model":"claude-opus-4-8","cwd":"/path/to/project"}
```
### Registration (immediately after hello)
Send registration frames in any order, then a single `ready`
sentinel so terva can finalize the agent's tool registry:
```json
{"type":"register_command","name":"weather","description":"current weather"}
{"type":"register_tool","name":"weather","description":"Get current weather for a city.",
"schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}
{"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]}
{"type":"ready"}
```
If you don't send `ready`, terva's idle watchdog auto-treats you as
ready after 250ms of no frames, but always send it explicitly when
you can — newer extensions on faster hosts shave that 250ms off.
`register_tool` accepts two optional classification fields that shape
how the host's approval modes gate your tool: `read_only: true` (the
tool has no side effects, so the host may run it in read-only / plan
modes without prompting) and `authority` (a finer effect class —
`local-read`, `workspace-mutation`, `process-execution`,
`network-read`, `external-mutation`; it supersedes `read_only`, so a
fetch declares `network-read` rather than being mistaken for a local
read). Both are additive — older hosts ignore them — and you should
declare them honestly: lying only loosens your own user's policy.
`essential: true` is a third, about visibility rather than gating: it
keeps the tool advertised every turn under lazy tool loading.
`display` is not a classification at all — it is how a rich client
draws the **card** for your tool's calls, and it changes nothing about
gating, visibility, or what the model sees:
```json
{"type":"register_tool","name":"weather","schema":{ },
"display":{"subject":"{city}","body":"table","redact":["api_key"]}}
```
`subject` is a template over your top-level argument keys, substituted
as text; without one a client guesses from key names and often lands on
"1 argument". `body` names a renderer the client already has (`text`,
`json`, `diff`, `table`) — a name, never a format string. `redact` names
argument keys the card masks, adding to the client's own denylist.
It is **data, never code**: a client that ran rendering code you supplied
would be running your code in someone's browser. From the Go SDK,
`ext.WithDisplay(ext.ToolDisplay{Subject: "{city}"})`. See
`docs/extensions.md` for the caps and the exact degradation rules.
### Runtime frames
**terva → extension:**
```json
{"type":"command_invoked","id":"abc","name":"weather","args":"berlin"}
{"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}}
{"type":"event","event":"turn_start","step":1}
{"type":"event_intercept","id":"ghi","event":"tool_call",
"tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}}
{"type":"shutdown"}
```
**extension → terva (replies + spontaneous notifications):**
```json
{"type":"command_response","id":"abc","action":"prompt",
"prompt":"Show today's weather for Berlin in one line."}
{"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
{"type":"event_intercept_response","id":"ghi","block":true,
"reason":"refused: command matches the danger pattern \"rm -rf\""}
{"type":"notify","level":"info","message":"refreshed cache"}
{"type":"clear_notes"}
{"type":"shutdown_ack"}
```
`notify` notes are one-shot: they clear when the user sends their next
prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
note this extension pushed earlier (e.g. a transient approval prompt)
without waiting for the next turn; other extensions' notes are kept.
`command_response.action` values:
- `"prompt"` — submit `prompt` as a fresh user message
- `"insert"` — drop `insert` into the editor at the cursor
- `"display"` — append `display` to chat as a one-shot note (no
model call, not in transcript)
- `"noop"` — handled internally; terva doesn't change the UI
`tool_result.content[]` blocks: `{"type":"text","text":"..."}` or
`{"type":"image","mime_type":"image/png","data":"<base64>"}`.
Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the
intercept timeout is treated as "allow" so an unresponsive guard
never stalls the agent.
### Protocol version negotiation (adopt optimistically)
`hello_ack` carries the host's `protocol_version`; your `hello` may
declare a `min_protocol`. The wire is additive and backward-compatible
by design, so the default posture is **optimistic adoption**: implement
the newest frames and fields you can, use them when the host offers
them, and degrade — never break — when it doesn't.
- **Adopt the latest by handling it, not by announcing it.** An
extension declares no protocol version — `hello` carries only your
extension `version` and an optional `min_protocol`. You keep current
simply by handling the newest frames and fields you understand and
ignoring the rest; add handling the moment you rely on a feature.
- **Gate on presence, not version math.** Detect a capability by a
field or event being present (`terva_version != ""`, a `session_id`
on `session_start`), not by comparing version strings — presence
survives renames and backports; version math rots.
- **Degrade, don't demand.** Newer features must be optional: fall back
to prior behavior when the host omits them, ignore unknown fields,
tolerate missing ones.
- **Reserve `min_protocol` for correctness floors.** It refuses to load
on an older host — pay that cost only when your extension is **wrong
or unsafe** without the feature, never merely less convenient.
- **Send forward-compatible fields unconditionally when harmless.** If
an older host ignores an unknown field (e.g. `authority` on
`register_tool`), always send it instead of gating on host identity —
simpler, and a newer host still benefits.
**Litmus test for `min_protocol`:** *if this feature is absent, is my
extension merely degraded, or incorrect?* Degraded → adopt
optimistically, no floor. Incorrect/unsafe → require it.
## Being responsible with context & tools
Two capabilities touch the model's **cached prompt prefix**: the static
context block (`register_context`, plus `refresh_context` to swap it
mid-life) and the visible tool set (`register_tool`, plus
`set_withdrawn_tools` to hide/restore your own tools). Providers cache that
prefix, so changing it re-bills and re-processes everything after it — in a
long session, a great deal. Treat the prefix as a **snapshot you replace at a
session boundary**, never per turn and never from a tool/event handler. (For
genuinely per-turn information, that's a context *card*, not the static
block.)
The host softens a mistimed change — it pins the prefix for the duration of a
turn (a mid-turn change can't disrupt the in-flight turn or evict its cache;
it lands on the next turn) and no-ops an unchanged set (re-asserting the same
decision on every `session_start`, including a `/cd` re-fire, is free) — but
the discipline is yours: **decide once per session and assert it from
`session_start` / `OnSession`.**
The two frames (both additive; feature-detect, don't demand):
- `refresh_context` (protocol 3) — replace your system-prompt block; an empty
string clears it. `{"type":"refresh_context","text":"…"}`
- `set_withdrawn_tools` (protocol 4) — hide/restore your **own** registered
tools. Wholesale snapshot: `{"type":"set_withdrawn_tools","all":true}` hides
them all, `{"type":"set_withdrawn_tools","tools":[]}` restores. Names that
aren't yours are ignored (you can't touch a built-in or another extension's
tool). A withdrawn tool stays registered, so restoring needs no
re-registration. An older host ignores the frame and the tools stay visible,
so gate on `protocol_version >= 4` rather than declaring `min_protocol`.
Withdraw tools that can't do their job in the current workspace — a git
extension outside a repo, a cloud tool with no credentials — so they stop
spending tokens in the model's schema and stop tempting calls that only
refuse; restore them when they become useful again.
In the Go SDK these live on the `Session` handed to `OnSession`, which is the
cache-safe call site (the same methods exist on `*Extension` for off-boundary
use but warn to the ext log):
```go
e.OnSession(func(s ext.Session) {
if usableHere(s.CWD) {
s.RefreshContext(standingNotes)
if s.ProtocolVersion() >= 4 { s.RestoreAllTools() }
} else {
s.RefreshContext("") // clear context
if s.ProtocolVersion() >= 4 { s.WithdrawAllTools() } // hide useless tools
}
})
```
Raw-protocol extensions (TS/Python) send the two frames directly from their
`session_start` handler, gating `set_withdrawn_tools` on the `protocol_version`
they saw in `hello_ack`. Fuller guidance and rationale:
`docs/extensions.md` → "Responsible use: context & tools".
## Important rules
- **stdout is reserved for the protocol.** Anything you print to
stdout that isn't a JSON frame breaks the wire. Use stderr for
logs / debug output (terva captures stderr to
`$TERVA_HOME/logs/ext-<name>.log`).
- **One JSON object per line.** No multi-line JSON. Always end
every frame with `\n`.
- **Flush after writing.** Most stdout writes are line-buffered when
piped, which is fine, but explicitly flushing avoids surprise
buffering on slow handlers.
- Extension processes inherit the user's permissions. A bad
extension can do anything the user can.
- **Change the model's cached prefix only at a session boundary.** The
context block and tool set are cached; swap them from `session_start` /
`OnSession`, never mid-turn or from a tool handler (see "Being responsible
with context & tools").
## Recommended layout per language
### Go (use the built-in SDK at packages/agent/ext)
```go
package main
import (
"encoding/json"
"terva.sh/terva/packages/agent/ext"
)
func main() {
e := ext.New("weather", "1.0.0")
e.Command("weather", "current weather for a city",
func(args string) ext.Response {
return ext.Prompt("Tell me the weather for " + args)
})
e.Tool("weather", "Get current weather for a city.",
json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
func(args json.RawMessage) ext.ToolResult {
var in struct{ City string `json:"city"` }
if err := json.Unmarshal(args, &in); err != nil {
return ext.TextErrorResult("invalid args")
}
return ext.TextResult(in.City + ": sunny, 21°C (fake)")
})
if err := e.Run(); err != nil {
e.Logf("fatal: %v", err)
}
}
```
Build: `go build -o weather .`
`extension.json`:
```json
{"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
```
### TypeScript (no SDK; handles the protocol directly)
Run via `tsx`, which executes `.ts` files without a build step.
```json
{"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true}
```
```typescript
// index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version)
import { createInterface } from "node:readline";
import { stderr, stdin, stdout } from "node:process";
function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
send({ type: "hello", name: "scratchpad", version: "1.0.0",
capabilities: ["commands", "tools"] });
send({ type: "register_command", name: "note", description: "append a note" });
send({ type: "register_tool", name: "read_notes",
description: "Read the user's scratchpad notes.",
schema: { type: "object", properties: {} } });
send({ type: "ready" });
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
rl.on("line", (line) => {
const f = JSON.parse(line);
if (f.type === "command_invoked" && f.name === "note") {
send({ type: "command_response", id: f.id, action: "display",
display: `noted: ${f.args}` });
} else if (f.type === "tool_call" && f.name === "read_notes") {
send({ type: "tool_result", id: f.id,
content: [{ type: "text", text: "(notes go here)" }] });
} else if (f.type === "shutdown") {
send({ type: "shutdown_ack" });
rl.close();
}
});
```
`tsx` install: `npm install -g tsx`. Without global tsx, fall back
to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower
startup; npx checks the registry every launch).
### Python
```json
{"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true}
```
```python
#!/usr/bin/env python3
import json, sys
def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
emit({"type": "ready"})
for line in sys.stdin:
msg = json.loads(line)
if msg["type"] == "command_invoked":
emit({"type": "command_response", "id": msg["id"],
"action": "prompt", "prompt": "Say hi briefly."})
elif msg["type"] == "shutdown":
emit({"type": "shutdown_ack"})
break
```
`chmod +x hello.py`.
## Install / dev workflow
```bash
terva ext install ./weather # copy into $TERVA_HOME/extensions/
terva --ext ./weather # run from disk for one terva session (no install)
terva --ext . # cwd is the extension dir
terva ext list # show installed extensions
terva ext logs weather # cat the extension's stderr
terva ext logs weather -f # tail it
terva ext disable weather # keep installed but skip on launch
terva ext enable weather
terva ext remove weather
```
For TS / Python extensions, no build step is needed — edit the source
in place and relaunch terva.
For Go, run `go build -o <name> .` in the extension directory after
edits, then `terva ext install` (which copies the manifest + binary)
or `terva --ext .` to test from the working tree.
## Manual debug
The extension is just a process. Drive it directly with shell pipes
to see exactly what's happening on the wire:
```bash
{
printf '%s\n' '{"type":"hello_ack","protocol_version":4,"terva_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
sleep 0.2
printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
sleep 0.5
printf '%s\n' '{"type":"shutdown"}'
} | ./weather
```
Compare what comes out of stdout to the expected wire format. If a
frame doesn't match what terva expects, it's discarded silently and
logged to `ext-<name>.log`.
## Process to follow with the user
1. Ask what the extension should DO. One sentence.
2. Pick the right capability:
- "I want a slash command that triggers a prompt" → `command` only
- "I want the model to be able to do X" → `tool`
- "I want to gate / log every bash command" → `event` + `intercept`
- "I want to add standing context, or show/hide my tools per
workspace" → `register_context`/`refresh_context` +
`set_withdrawn_tools`, driven from `OnSession` (read "Being
responsible with context & tools" first)
3. Pick a language. Default to **Go via packages/agent/ext** for new
extensions if the user has Go installed; **TypeScript via tsx**
if they prefer JS-flavored ergonomics; **Python** for one-off
scripts.
4. Write the extension dir (manifest + source).
5. For Go, build it. For TS / Python, mark the script executable.
6. Suggest `terva --ext <path>` for testing without committing to an
install.
7. When happy, `terva ext install <path>`.
Don't try to write a full SDK or framework on top of the protocol
unless the user asked for one — the wire format is small enough
that a 30-line raw script is the right answer for most extensions.