Extend the agent with a module skill
The tasklist tutorial wired a Go web app to a Tavora agent via MCP. The agent can drive your domain through tools you host. But sometimes you don’t want to host anything — you just want the agent to compose a few primitives in one place and call it by name. That’s what module skills are for.
By the end of this walkthrough you’ll have added a summarize_list
skill to the tasklist agent — a require()-able JS module that
fetches a list’s tasks via MCP, asks the LLM to summarise them, and
returns a one-liner. The agent calls it like any other function.
When to reach for a skill vs MCP
Section titled “When to reach for a skill vs MCP”| Use a module skill when… | Use an MCP server when… |
|---|---|
| The logic is pure in-sandbox composition: branches, loops, transforms, LLM sub-calls, retrieval, memory. | The action talks to your domain — your database, your file storage, your third-party APIs. |
| You want the agent to call something by name without re-explaining it every turn. | You already host the capability behind an HTTP endpoint and want Tavora to call it. |
| You’re happy to author and ship it as part of the agent’s source. | The work needs to happen on your infrastructure for compliance, auth, or latency reasons. |
| ES5.1 JS, 30-second cap, no direct network. | Anything beyond those limits. |
Common pattern: skills wrap MCP. The agent calls
require('summarize_list') (skill), which calls
require('tasklist-example').list_tasks (MCP) and ai(...) (LLM
primitive), and returns the synthesized result. One sandbox step, one
audit row, one billable LLM call — instead of three model turns.
What you’ll build
Section titled “What you’ll build”A second require()-able module the tasklist agent can call:
require('summarize_list')({ list_id: 'lst_abc' })// → "A six-step Berlin trip covering travel, sights, and food."The skill’s body composes two primitives the runtime exposes:
require('tasklist-example').list_tasks(...)— the MCP tool declared in the previous tutorial’sagent.jsonc.ai(prompt)— single-shot LLM call through the platform’s provider router. Same audit log, same metering, same BYO-keys path as everything else.
sequenceDiagram
box rgba(251,239,233,0.65) Tavora sandbox
participant Agent as Agent (think step)
participant Skill as summarize_list
end
box rgba(226,234,247,0.55) Your stack
participant MCP as tasklist /mcp
participant LLM as Provider (Gemini/…)
end
Agent->>Skill: require('summarize_list')({list_id})
Skill->>MCP: list_tasks({list_id}) — MCP tool
MCP-->>Skill: tasks[]
Skill->>LLM: ai("Summarise: " + titles)
LLM-->>Skill: one-line summary
Skill-->>Agent: summary string
Step 1 — Author the skill as a file
Section titled “Step 1 — Author the skill as a file”Skills are .js (module) and .md (prompt) files under
tavora/agents/<id>/skills/. The .js body runs with args bound
to the input object. The runtime is Goja, ES5.1 only — var,
function (…), string concatenation; no arrow functions, no let /
const, no template literals, no destructuring.
//// summarize_list — fetch a list's tasks via MCP and ask the LLM// for a one-line summary.
if (!args.list_id) { return { error: 'list_id required' };}
var mod = require('tasklist-example');var result = mod.list_tasks({ list_id: args.list_id });var tasks = result.content.tasks || [];
if (tasks.length === 0) { return { summary: 'List is empty.', count: 0 };}
var titles = tasks.map(function (t) { return t.title; }).join('; ');var summary = ai( 'Summarise this set of tasks in one short sentence (max 20 words): ' + titles);
return { summary: summary, count: tasks.length };A few intentional choices worth flagging:
- Returns an object, not a string. The caller can read
result.summaryandresult.countseparately — easier to compose than parsing a sentence. - Defensive on input.
args.list_idmissing is a real failure mode; surface it as a structured error so the LLM can react. - No hardcoded list name. Pure transformation — works for any list the agent knows the id of.
Step 2 — Sync + watch the agent use it
Section titled “Step 2 — Sync + watch the agent use it”tavora dev is already watching. Save the file, the draft syncs.
Update the agent’s persona so the LLM knows about the new helper:
You manage task lists and tasks by calling the MCP tools registeredwith this agent (create_task_list, list_task_lists, delete_task_list,add_task, list_tasks, complete_task).
You also have a `summarize_list` skill: pass it { list_id } and itreturns { summary, count }. Prefer it over reading every task titleyourself when the user asks "what's on …" or "how does … look".Verify locally without leaving your terminal:
tavora run tasklist "How's my Berlin trip list looking?"tavora run exercises the synced draft and writes a markdown trace
to tavora/.runs/<ts>-tasklist-<sid>.md. Read it the way you’d read
any other file.
When the draft does what you want, ship it:
tavora deployThe agent’s think step in the trace now looks roughly like this:
var mod = require('tasklist-example');var list = mod.list_task_lists().content.lists.find(function (l) { return l.name.toLowerCase().indexOf('berlin') >= 0;});if (!list) return "I don't see a Berlin list.";
var s = require('summarize_list')({ list_id: list.id });return s.summary;One think step. Two MCP calls (list_task_lists +
list_tasks inside the skill). One LLM sub-call (ai(...)
inside the skill). The agent didn’t burn a turn per primitive — it
composed them in a single program.
In the trace you’ll see:
execute_js ← the agent's JS sourcesandbox_event ← tool_call: tasklist-example.list_task_listssandbox_event ← tool_call: summarize_list (the skill itself)sandbox_event ← tool_call: tasklist-example.list_tasks (nested)sandbox_event ← ai_call: ~0.3s, 84 prompt + 18 completion tokenssandbox_event ← tool_result: { summary: "…", count: 6 }response ← "A six-step Berlin trip covering travel, sights, and food."done ← summary: 1 step, total tokensEvery primitive call surfaces as a sandbox_event you can inspect
in the session detail view at /sessions/<session-id>.
Authoring tips
Section titled “Authoring tips”log(...)is your friend. Calls tologemitsandbox_events straight into the trace. Sprinkle them while developing; remove for production.- Return structured data when possible. Strings work but objects
compose better — the agent can read
result.countto decide whether to call again. - Don’t loop without a cap. The runtime cuts off at 30 s per step, but a runaway loop is wasted compute. Guard with explicit upper bounds.
- Idempotency matters for retries. If a skill mutates state and
the agent retries the same
thinkstep (e.g. after a transient network error), you don’t want duplicates. Read first, write only on a clean diff. - No filesystem, no subprocess, no direct network. Use
fetch(url, opts)for HTTP — it goes through the app’s whitelist.require()-ing another skill or MCP server is the only way to reach outside the sandbox.
What’s deliberately out of scope
Section titled “What’s deliberately out of scope”This walkthrough adds one skill. Real systems will grow to dozens. At that scale you’ll want:
- Eval cases that exercise the skill. Drop a JSON file under
tavora/agents/tasklist/evals/that asserts the expectedsummaryshape;tavora deploy --run-evalsreports a pass-rate. - Skill type-check at save. The platform diffs the
.d.tsdeclarations in a module’s JSDoc header against the JS body’sexports.NAME = …assignments and surfaces drift as a warning at edit time. - Mining skills from session traces. Patterns the agent keeps
re-emitting in
thinksteps can be auto-extracted as new skills (concept doc in tavora-go; not shipped yet).
Next steps
Section titled “Next steps”- Skills — full sandbox primitive reference, authoring constraints, file layout.
- MCP servers — pair skills with MCP tools the way this walkthrough did.
- Tasklist tutorial — the MCP integration this builds on.