Skip to content
Back to skills

Write Terva Extension

BSecurity

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.

  • 3 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 2, 2026
ai-agentstypescriptpythonrustgoshellbashnodeawstestinggit

Works with

  • cursor
  • cli
  • api

Security analysis

B85/100
  • highPerforms destructive filesystem operations
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 20, 2026

npx -y skills add terva-sh/terva --skill write-terva-extension --agent claude-code

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.

Security grade badge for Write Terva Extension
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/terva-sh-write-terva-extension/badge)](https://www.skillsdirectory.com/skills/terva-sh-write-terva-extension)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.