Skip to content

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.

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 /mcp exposing 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.md
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.

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.

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:

tavora/agents/tasklist/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"
}
}
]
}
tavora/agents/tasklist/persona.md
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:

Terminal window
# One-time per project
tavora init
# Mint a secret into the app's vault (admin web UI or `tavora`)
# Then publish the agent
tavora deploy

tavora 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.

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.

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.

Terminal window
# Terminal 1: the Tavora stack (see tavora-go README)
task dev
# Terminal 2: configure + deploy + run the example
tavora login # writes ~/.tavora.yaml
# Mint TASKLIST_BEARER into the app's secret vault (admin UI or CLI)
cd examples/tasklist
tavora deploy # ships tavora/agents/tasklist/
export TASKLIST_AGENT_ID=... # from deploy output
go run .
# Browser: http://localhost:8090

Good 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.

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 to tavora/.runs/. Read it with any text tool.

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_id in 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/*.json files under tavora/agents/tasklist/ and run tavora deploy --run-evals for 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.

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.