Building a Skill — the Tonoman Skill SDK
A Skill is a capability you give an agent: it takes one item of work (a recording, a document, an event), does something useful with it, and reports an outcome. The Plaud meeting-recap — transcribe a recording, summarise it against the tenant's mission, file it in the second brain — is the first Skill, and this guide is written so a developer or a coding agent can build the next one quickly.
The one idea to hold onto: a Skill is a self-contained CLI. It has its own entrypoint and dependencies, it talks to Tonoman through a small, fixed contract (environment + stdin/stdout + a few HTTP capabilities), and it imports the SDK and nothing else from the runtime. That boundary is what lets the same Skill run on your laptop and inside Tonoman unchanged, and what lets it live in its own git repo.
Status (2026-09-12). The contract on this page is locked in code (src/skill-sdk). The runtime that runs a Skill against it — therunClibootstrap, the capability plane, and the local dev harness — is landing increment by increment; sections below are marked _(available)_ or _(landing)_ so you know what you can run today. The shape will not change under you.
See also. For how a run is wired — the sequence from trigger to the agent's announcement, the outcome contract, and the capability plane — read the feature doc `../features/tonoman-skills.md`. This page is how to build a Skill; that one is how the runtime runs it.
---
1. Anatomy of a Skill
A Skill is a folder. First-party Skills live in this repo under src/skills/<group>/<name>/; a third-party Skill is the same shape in its own repo.
src/skills/voice/plaud-and-calendar-meetings/
manifest.ts # what the Skill is, needs, and can be configured with
run.ts # the work: one item in, one outcome out
index.ts # wires the SDK bootstrap to your run() — the CLI entrypointWritten in TypeScript or JavaScript (it runs on Node). TypeScript is the default and what the examples use; plain JS works the same way.
---
2. The manifest — what your Skill declares
The manifest is data. It is how Tonoman knows what your Skill needs before it runs it, and it is what the Hub renders a configuration form from. Changing it is a code change and a new version.
import type { SkillManifest } from '../../../skill-sdk';
export const manifest: SkillManifest = {
name: 'meeting-recap', // the canonical id — stable; the DB, roster and run records key on it
version: 1, // bump when requires/capabilities/configSchema change
description: 'Transcribe a recording, summarise it against the mission, and file it.',
// RAW third-party credentials your Skill holds and uses DIRECTLY.
requires: [
{ kind: 'plaud' }, // cannot run without it
{ kind: 'calendar', optional: true }, // works without it — just does less
],
// MEDIATED capabilities you call through Tonoman (see §4). You never hold the provider keys for
// these — Tonoman routes, meters and bills them.
capabilities: ['transcribe', 'infer', 'publish'],
// Typed fields the installing agent fills; delivered back to you in ctx.input.config.
configSchema: [
{ key: 'output_channel', type: 'channel', label: 'Where recaps are announced' },
],
};`requires` vs `capabilities` is the whole design. A credential is a third-party account your Skill talks to itself (Plaud, a calendar feed) — Tonoman hands you the login. A capability is a service Tonoman performs on your behalf (transcription, inference, filing to the second brain) so that provider choice, cost and the tenant's data stay Tonoman's, never baked into your Skill. If you find yourself wanting a provider API key in requires, it probably belongs behind a capability.
configSchema field types: channel, channel_list, text, toggle.
---
3. run() — the work
Your Skill exports a run(ctx) that takes the context Tonoman builds and returns an outcome. It is pure orchestration over ctx: no `process`, `env`, or direct stdout — the SDK owns that boundary.
import type { SkillRun } from '../../../skill-sdk';
export const run: SkillRun = async (ctx) => {
const { item, config } = ctx.input;
// A raw credential — use it directly. For a refreshable login, ask for a fresh one:
const plaud = await ctx.credential('plaud');
ctx.progress('transcribing');
const audioUrl = await fetchRecordingUrl(plaud, item); // your code, your dependency
const { text } = await ctx.cap.transcribe({ audio: { url: audioUrl }, vocab: 'Acme, Tonoman' });
if (!text.trim()) return { status: 'skipped', reason: 'no speech' };
ctx.progress('summarising');
// You own the prompt and the parse; the runtime runs it on the agent's own provider.
const { text: json } = await ctx.cap.infer({
system: 'Summarise this meeting as STRICT JSON: {"title","body","route"}.',
user: `Transcript:\n${text}`,
});
const recap = JSON.parse(json) as { title: string; body: string; route: string };
ctx.progress('filing');
const filed = await ctx.cap.publish({ route: recap.route, title: recap.title, body: recap.body });
return {
status: 'done',
summary: `Filed “${recap.title}” under ${recap.route}.`, // announced verbatim by Tonoman
steer: `A recap for ${item} is in the second brain.`, // a hint the agent may act on
};
};Report, don't speak
A Skill does not talk to the channel. It returns a SkillOutcome:
status:'done' | 'skipped' | 'failed'— what the run record closes as.summary?: a human line Tonoman announces verbatim through the agent's existing say path.steer?: a hint the agent consumes to decide any follow-up to bubble to the user.reason?: why it was skipped or failed — recorded, not announced.
This keeps a Skill from holding a Slack channel, and keeps "what to say" a judgment the agent makes, not something hard-coded in every Skill.
---
4. Capabilities — what Tonoman does for you
You call these through ctx.cap. Tonoman performs them with the tenant's configured providers; you supply only what the work needs.
ctx.cap.transcribe({ audio, vocab?, onProgress? })
Turns audio you fetched into text. audio is { url } Tonoman can fetch, or { bytes, contentType }. The engine (groq, a Cloud-hosted local GPU) and its fallback order are the tenant's provider chain, resolved below you — you get { text, seconds, by }. Call onProgress(done, total) for long, chunked audio so the run's heartbeat stays alive.
ctx.cap.infer({ system, user })
Runs an inference on the agent's own inference provider — the Claude Code harness (its subscription), the same brain that answers the agent's messages — not a side model with its own key. A recap is the agent thinking about the meeting, so it runs on the agent's plan. You own the prompt (system + user) and parse the returned text; the runtime owns which provider, the budget and the billing. The provider is claude-code today; codex and an OpenAI subscription plug in at one dispatch point later, and your Skill never changes — it just calls ctx.cap.infer.
ctx.cap.publish({ route, title, body, meta? })
Files an artifact in the tenant's git-backed second brain and returns { published, path, url? }. The repo, credentials and routing are Tonoman's; you supply content and a route hint.
Announcing is deliberately not a capability — see §3. Transcription is a capability, not a credential: that is why it is absent from requires.---
5. The context your Skill receives
interface SkillContext {
input: { item: string; config: Record<string, unknown>; user?: string };
creds: Record<string, unknown>; // raw credentials by kind
credential(kind: string): Promise<unknown>; // exchange a ref for a FRESH, refreshable credential
cap: { transcribe; infer; publish }; // §4
progress(note: string): void; // keeps Temporal's heartbeat alive; side channel
log(msg: string): void; // stderr; never stdout
}ctx.creds carries short-lived credentials directly. For a long-lived login (Plaud's OAuth, which must refresh across a 62-minute transcription), call ctx.credential(kind) when you need a token — Tonoman returns a fresh one, so your Skill never manages refresh itself.
---
6. How a Skill runs — the contract under the SDK
You rarely touch this directly (the SDK's runCli does), but it is why a Skill is portable:
- Environment carries resolved credentials,
TONOMAN_CAPABILITY_URL+ a scoped token, and config. - stdin carries the item to work and its config (one JSON object).
- stdout carries exactly one thing: the
SkillOutcomeJSON. Nothing else may be written there. - stderr / a progress stream carries
ctx.progressandctx.log— kept separate so a large outcome can never starve the heartbeat.
Tonoman's worker spawns your CLI as a subprocess under Temporal, so you get retries, heartbeats and exactly-once-per-item semantics for free — and because the work is a subprocess, not workflow code, you are under no determinism constraints. Write ordinary imperative code.
index.ts wires it up _(landing)_:
import { runCli } from '../../../skill-sdk';
import { manifest } from './manifest';
import { run } from './run';
runCli(manifest, run); // reads env + stdin, builds ctx, calls run(), writes the outcome---
7. How a Skill is triggered
A Skill runs on a schedule, on demand, or both — the install decides:
- Scheduled. A cadence config (
poll_seconds) wakes the Skill every N seconds to look for new items. Set it to 0 to turn the schedule off entirely — the Skill then runs on demand only. - On demand, like a tool.
!skill <name> <item>(in the agent's channel) runs the Skill on one item immediately, out of band from any schedule. Scheduled and on-demand runs share one per-item workflow id, so the same item is never processed twice.
8. Running it
In Tonoman: the agent is granted the Skill in the Hub; the worker spawns your CLI (scheduled or on demand) with the environment above. You do nothing per-run. The agent's Claude CLI is already authenticated there — which is exactly why ctx.cap.infer can run on its subscription.
Locally _(dev harness, landing)_: your Skill runs against the capability endpoints with your own keys — and crucially, your `claude` CLI must already be logged in, because infer is a cross-call to that same CLI (the agent's inference provider). That is the one setup step; it mirrors the authenticated-harness state Tonoman's runSkill assumes. "Works on my laptop" then means "works in Tonoman."
---
9. Checklist for a new Skill
- Create
src/skills/<group>/<name>/withmanifest.ts,run.ts,index.ts. - Declare
requires(raw credentials),capabilities(mediated), andconfigSchema. - Write
run(ctx): fetch with your credential, call capabilities, return an outcome — report, don't speak. - Keep stdout for the outcome only; use
ctx.progress/ctx.logfor everything else. - Prove it locally with the dev harness on a real item before it runs in Tonoman.
See docs/architecture.md §A15 for the runtime/Cloud boundary behind all of this.