Build the tasklist agent
The tasklist example in
tavora-sdk-go/examples/tasklist
is the canonical end-to-end demonstration of the integration pattern
Tavora is built around: your app keeps the data and the UI; the
agent (authored as files in your tavora/ folder) drives them through
an MCP server you host.
By the end of this walkthrough you’ll have a small Go web app — a
Reminders-style task list — where typing “Create a task list of all
large German cities to visit” into the chat panel causes an agent to
call create_task_list plus six add_tasks in real time, and the
sidebar refreshes live as each tool call lands.
What you’ll build
Section titled “What you’ll build”A single Go binary that:
- Stores task lists in a local SQLite file.
- Serves an HTML page with a sidebar (lists/tasks) and a chat panel.
- Hosts an MCP server at
/mcpexposing six tools to Tavora:create_task_list,list_task_lists,delete_task_list,add_task,list_tasks,complete_task. - Spawns an agent session per chat message and streams the agent’s reasoning trace back to the browser as Server-Sent Events.
Plus a tavora/ folder alongside the example that holds the agent
itself:
examples/tasklist/├── main.go├── internal/│ ├── store/ SQLite schema + CRUD│ └── web/│ ├── server.go chi router│ ├── lists.go JSON API the SPA reads│ ├── mcp.go the six MCP tools│ ├── chat.go agent session + SSE│ └── templates/index.html├── tavora/ ← agent authored as files│ ├── tavora.jsonc│ └── agents/tasklist/│ ├── agent.jsonc ← persona + mcp + model│ └── persona.md└── README.mdThe integration shape
Section titled “The integration shape”sequenceDiagram
box rgba(226,234,247,0.55) Your app
participant Browser
participant App as Your Go server
participant DB as SQLite
end
box rgba(251,239,233,0.65) Tavora
participant T as Tavora API
end
Note over App,T: 1. One-time author: tavora deploy
Note over Browser,App: 2. Chat turn
Browser->>App: POST /api/chat {message}
App->>T: CreateAgentSession + RunAgent
T->>App: POST /mcp (JSON-RPC tools/call)
App->>DB: insert task
App-->>T: tool result
T-->>App: SSE: AgentEvent stream
App-->>Browser: SSE: forward each event
Browser->>App: GET /api/lists (sidebar refresh)
Two loops cohabit one binary: a JSON API your SPA hits, and an MCP
endpoint Tavora hits. The agent itself is authored once (and re-shipped
via tavora deploy whenever you change agent.jsonc or persona.md)
— it isn’t created by your binary at boot.
Step 1 — Boot your domain
Section titled “Step 1 — Boot your domain”main.go is plain Go: open SQLite, wire up the router, start
listening on APP_PORT. The SDK isn’t involved here — Tavora is the
agent layer, not the backend.
st, err := store.Open(dbPath)if err != nil { return fmt.Errorf("open db: %w", err)}
srv := web.NewServer(st)http.ListenAndServe(":"+port, srv)The full Store type wraps modernc.org/sqlite (CGo-free) and
exposes CreateList, ListLists, AddTask, ListTasks,
CompleteTask, DeleteList — every operation the MCP tools need.
That’s it for the backend half.
Step 2 — Author the agent in tavora/
Section titled “Step 2 — Author the agent in tavora/”The agent’s persona, model, and MCP binding live as files under
tavora/agents/tasklist/. There’s no SDK call to “register an MCP
server” at boot — that responsibility moved into agent.jsonc:
{ "id": "tasklist", "name": "Tasklist assistant", "persona": "persona.md", "model": "gemini-2.5-flash", "mcp": [ { "name": "tasklist-example", "url": "${APP_PUBLIC_URL}/mcp", "transport": "streamable_http", "auth": { "type": "bearer", "tokenRef": "TASKLIST_BEARER" } } ]}You are a task-list assistant for an internal Reminders-style app.
Guidelines:- When the user asks you to create a list of items, first call create_task_list to get a list_id, then call add_task repeatedly.- If the user refers to an existing list by name, call list_task_lists first to find its id.- Do not invent IDs. Only use IDs returned by tools.${APP_PUBLIC_URL} resolves server-side from the app’s environment;
TASKLIST_BEARER resolves from the app’s secret vault. Neither value
ever leaves your shell.
Ship it:
# One-time per projecttavora init
# Mint a secret into the app's vault (admin web UI or `tavora`)# Then publish the agenttavora deploytavora deploy writes the agent + its MCP binding. The deploy
output prints the server-side agent ID — you’ll pass it on
CreateAgentSession in Step 4.
Step 3 — Expose tools at /mcp
Section titled “Step 3 — Expose tools at /mcp”The MCP server is wired up with
github.com/modelcontextprotocol/go-sdk:
srv := mcp.NewServer(&mcp.Implementation{Name: "tasklist", Version: "0.1.0"}, nil)
mcp.AddTool(srv, &mcp.Tool{ Name: "add_task", Description: "Append a task to a list", }, func(ctx context.Context, _ *mcp.CallToolRequest, in addTaskIn) (*mcp.CallToolResult, addTaskOut, error) { id, err := st.AddTask(in.ListID, in.Title) if err != nil { return nil, addTaskOut{}, err } return nil, addTaskOut{ID: id}, nil },)// … five more AddTool calls, one per tool …A bearer-gate middleware checks Authorization: Bearer <secret> on
every request to /mcp, validating the same shared secret you stored
in the app’s vault under TASKLIST_BEARER. Anything else gets a
401.
Each tool is a pure (input) → (output) function that talks to
Store. No agent-specific code, no SSE plumbing — just your domain
API, surfaced over MCP.
Step 4 — Spawn an agent session per turn
Section titled “Step 4 — Spawn an agent session per turn”When the browser POSTs /api/chat {message}, the handler creates a
fresh session bound to the deployed tasklist agent and forwards
every SSE event back to the browser:
session, err := s.Tavora.CreateAgentSession(r.Context(), tavora.CreateAgentSessionInput{ AgentID: os.Getenv("TASKLIST_AGENT_ID"), // from `tavora deploy`'s output})if err != nil { /* … */ }
err = s.Tavora.RunAgent(r.Context(), session.ID, in.Message, func(evt tavora.AgentEvent) { // Forward every event to the browser via SSE. fmt.Fprintf(w, "data: %s\n\n", marshal(evt)) flusher.Flush()})The agent’s persona, model, and MCP binding all come from what
tavora deploy shipped — your binary doesn’t repeat any of that
inline.
Step 5 — What the agent actually does
Section titled “Step 5 — What the agent actually does”Given “Create a task list of all large German cities to visit”, the
LLM emits a single think step that looks roughly like this:
var mod = require('tasklist-example');
var list = mod.create_task_list({ name: 'Large German cities to visit' });var cities = ['Berlin', 'Hamburg', 'Munich', 'Cologne', 'Frankfurt', 'Stuttgart'];for (var i = 0; i < cities.length; i++) { mod.add_task({ list_id: list.content.id, title: cities[i] });}return 'Created list "Large German cities to visit" with ' + cities.length + ' tasks.';One think step. Seven MCP calls. The agent doesn’t burn one
LLM turn per tool call — it writes a loop and the sandbox executes it.
That’s the code-reasoning core, demonstrated end-to-end.
The browser receives execute_js (the JS source) → seven
sandbox_event / tool_call / tool_result pairs → response
(the final “Created list…” string) → done (with token totals). The
sidebar polls /api/lists after done and refreshes — the tasks are
already in SQLite by then.
Step 6 — Run it
Section titled “Step 6 — Run it”# Terminal 1: the Tavora stack (see tavora-go README)task dev
# Terminal 2: configure + deploy + run the exampletavora login # writes ~/.tavora.yaml# Mint TASKLIST_BEARER into the app's secret vault (admin UI or CLI)
cd examples/tasklisttavora deploy # ships tavora/agents/tasklist/export TASKLIST_AGENT_ID=... # from deploy output
go run .
# Browser: http://localhost:8090Good prompts to try in the right-hand chat panel:
- Create a task list of all large German cities to visit.
- Add “Check passport” and “Book trains” to that list.
- Which lists do I have?
- Mark “Berlin” as done.
- Delete the “groceries” list.
Every tool call streams in live (→ add_task(...) / ← add_task ok),
and the sidebar refreshes after each turn completes.
Read the trace
Section titled “Read the trace”Each turn logs a session ID. Pull the full reasoning trace:
- In the browser: open
/sessions/<session-id>for a scrollable enriched view with JS, sandbox calls, token costs. - Locally: re-run the same prompt with
tavora run tasklist "<message>"— it streams against the local draft and writes a markdown trace totavora/.runs/. Read it with any text tool.
What’s deliberately out of scope
Section titled “What’s deliberately out of scope”tasklist is a template, not a production stack. It skips:
- Multi-user auth. The example’s web UI has none — it’s a dev toy.
- Per-tenant isolation. One example process = one SQLite file =
one shared task-list namespace. A real SaaS would scope tasks to
the authenticated
customer_idin your own backend; Tavora itself is app-scoped, so isolation is your concern, not the platform’s. - Eval gates. The example doesn’t pin an eval suite to the agent.
In production, drop
evals/*.jsonfiles undertavora/agents/tasklist/and runtavora deploy --run-evalsfor an advisory pass-rate signal.
The tasklist pattern is what every customer-facing integration looks like, minus those production hardening passes. Treat it as the shape, not the destination.
Why not webhook skills?
Section titled “Why not webhook skills?”Skills authored as .js files under tavora/agents/<id>/skills/ give
you everything a webhook skill used to — you can fetch() from inside
the module body. MCP is the right mechanism when the tool is part of
your domain API; module skills are the right mechanism for in-sandbox
composition.
Next steps
Section titled “Next steps”- Extend with a module skill — add a
require()-able JS file alongside the MCP binding. - MCP servers — the JSONC shape and validation rules.
- Skills — module + prompt skills for in-sandbox composable logic.