Skip to content

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.

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.

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’s agent.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

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 onlyvar, function (…), string concatenation; no arrow functions, no let / const, no template literals, no destructuring.

tavora/agents/tasklist/skills/summarize_list.js
//
// 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.summary and result.count separately — easier to compose than parsing a sentence.
  • Defensive on input. args.list_id missing 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.

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 registered
with 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 it
returns { summary, count }. Prefer it over reading every task title
yourself when the user asks "what's on …" or "how does … look".

Verify locally without leaving your terminal:

Terminal window
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:

Terminal window
tavora deploy

The 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 source
sandbox_event ← tool_call: tasklist-example.list_task_lists
sandbox_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 tokens
sandbox_event ← tool_result: { summary: "…", count: 6 }
response ← "A six-step Berlin trip covering travel, sights, and food."
done ← summary: 1 step, total tokens

Every primitive call surfaces as a sandbox_event you can inspect in the session detail view at /sessions/<session-id>.

  • log(...) is your friend. Calls to log emit sandbox_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.count to 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 think step (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.

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 expected summary shape; tavora deploy --run-evals reports a pass-rate.
  • Skill type-check at save. The platform diffs the .d.ts declarations in a module’s JSDoc header against the JS body’s exports.NAME = … assignments and surfaces drift as a warning at edit time.
  • Mining skills from session traces. Patterns the agent keeps re-emitting in think steps can be auto-extracted as new skills (concept doc in tavora-go; not shipped yet).
  • 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.