keelwave
Quickstart

TypeScript Quickstart

Install the TypeScript SDK, wrap your agent, and see runs in the dashboard in five minutes.

Instrument a Vercel AI SDK agent and see its decision trace, token usage, and any loops in the dashboard.

Prerequisites

  • A running keelwave server. Follow Self-hosting with Docker. The API and dashboard both listen on http://localhost:8080.
  • An API key. make seed in core/ creates a dev project and prints a plaintext kw_... key. The server only stores its SHA-256 hash, so copy it when it is printed.
  • Node.js 18 or newer.

Keep the kw_... key out of source control. Every example below reads it from the environment.

Install

npm install keelwave

The Vercel AI SDK is an optional peer dependency — core tracing never imports it. Install it only if you use the keelwave/vercel-ai adapter:

npm install ai @ai-sdk/openai zod

zod is used below to describe tool parameters to the AI SDK; it is not a keelwave dependency.

Set your environment

export KEELWAVE_API_KEY=kw_...              # from `make seed`
export KEELWAVE_ENDPOINT=http://localhost:8080
export OPENAI_API_KEY=sk-...

KEELWAVE_API_KEY and KEELWAVE_ENDPOINT are a convention used by the examples, not magic names — the SDK reads nothing from the environment on its own. You pass both to the constructor explicitly.

Initialize the client

import { Keelwave } from 'keelwave'

const client = new Keelwave({
  apiKey: process.env.KEELWAVE_API_KEY ?? '',
  endpoint: process.env.KEELWAVE_ENDPOINT ?? 'http://localhost:8080',
})

endpoint defaults to http://localhost:8080 and a trailing slash is stripped. By default the SDK is fail-soft: ingest errors are logged as warnings and never thrown into your agent. Pass raiseOnError: true to surface them instead — useful while you are wiring things up.

Check the connection before you go further:

console.log(await client.health())

Instrument the agent

Three pieces do the work:

  • wrapModel(client, model) from keelwave/vercel-ai wraps any LanguageModelV1 in middleware. Every generateText / streamText call through it is recorded as an LLM trace with model, provider, tokens, latency, and status.
  • client.observe({ name, stepType }) returns a decorator that wraps a function. With the default stepType of 'tool_call' it emits a tool call step whose input is fingerprinted, so a repeated call with identical arguments flags the run as looping.
  • client.agent({ name }) wraps an async function in a run. It opens the run before the body and closes it after, recording completed / failed, duration, and the totals. Steps emitted inside link to the run through AsyncLocalStorage, so you never pass a run object around.

Save this as agent.ts:

import { openai } from '@ai-sdk/openai'
import { generateText, tool } from 'ai'
import { Keelwave } from 'keelwave'
import { wrapModel } from 'keelwave/vercel-ai'
import { z } from 'zod'

const client = new Keelwave({
  apiKey: process.env.KEELWAVE_API_KEY ?? '',
  endpoint: process.env.KEELWAVE_ENDPOINT ?? 'http://localhost:8080',
})

const model = wrapModel(client, openai('gpt-4o'))

const webSearch = client.observe({ name: 'web_search', stepType: 'tool_call' })(
  async (q: string): Promise<{ results: Array<string> }> => {
    // Replace with a real search implementation.
    return { results: [`result for: ${q}`] }
  },
)

const runAgent = client.agent({ name: 'research-agent' })(async (
  question: string,
): Promise<string> => {
  const { text } = await generateText({
    model,
    prompt: question,
    tools: {
      web_search: tool({
        description: 'Search the web for a query.',
        parameters: z.object({ q: z.string() }),
        execute: async ({ q }) => webSearch(q),
      }),
    },
    maxSteps: 8,
  })
  return text
})

console.log(await runAgent('what changed in our churn numbers last week?'))

Run it

npm install -D tsx
node --import tsx agent.ts

tsx runs the TypeScript file directly. On Node 22.6+ you can instead use node --experimental-strip-types agent.ts, or compile with tsc first.

The agent prints its answer as usual. Instrumentation is fail-soft by default: if the keelwave server is unreachable, the SDK warns and your agent keeps running.

See it in the dashboard

Open http://localhost:8080 and sign in. The user make seed creates has no password and is not email-verified, so it cannot sign in — register an account through the dashboard for UI access, as described in Docker.

  • Runs — your research-agent run appears with its status, step count, token total, cost, and duration, alongside the project-wide completion rate and loop rate.
  • Run detail — click the run for its step timeline in order: each web_search tool call with its input, output, and success flag.
  • Toolsweb_search shows up in the tool registry with its success rate and latency.

If the same search runs twice with identical arguments, the second call matches the first fingerprint and the run is flagged as looping, with the step index where the repetition began.

Without decorators

If decorators don't fit, open the run yourself. client.run takes the agent name and a callback that receives the Run; it POSTs the run before the callback, sends each step as it happens, and finishes the run afterwards — including marking it failed when the callback throws.

await client.run('research-agent', async (run) => {
  await run.step('think', 'need to search first')
  await run.toolCall('web_search', { q: question }, { results: [] })
  run.setOutput('done')
}, { input: question })

run.toolCall fingerprints the tool name and input for you. run.step does not — call run.checkFingerprint(name, input) yourself, or run.markLoop() when you have detected a loop by other means.

Spans

For work that isn't a tool call, client.span emits a custom step on the active run:

const span = client.span('retrieval', 'fetch_docs').start()
const docs = await fetchDocs(query)
span.set(`fetched ${docs.length} docs`)
await span.end()

Span also implements Symbol.asyncDispose, so with the explicit resource management syntax you can write await using span = client.span('retrieval') and let it emit at the end of scope.

Reading the current run

getCurrentRun() returns the Run for the enclosing client.run or client.agent call, or undefined outside one:

import { getCurrentRun } from 'keelwave'

const run = getCurrentRun()
run?.markLoop()

Next steps

On this page