Skip to content

SDK Quickstart

Get @tuvl/client installed and making live calls in under 5 minutes.

Prerequisites

  • tuvl server running (tuvl dev or tuvl run) — see Installation
  • A Bearer token — in dev mode the TUVL_DEV_API_KEY from your .env works

1. Install

npm install @tuvl/client
pnpm add @tuvl/client
yarn add @tuvl/client

2. Create a client

import { TuvlClient } from "@tuvl/client";

const client = new TuvlClient({
  baseUrl: "http://localhost:8000",
  token: process.env.TUVL_TOKEN, // (1)
});
  1. In a browser app, pass the token obtained from your login flow. In dev mode, use TUVL_DEV_API_KEY from your .env.

3. Call a workflow (REST)

const result = await client.execute("hello", {
  payload: { message: "world" },
});

console.log(result); // "Echo: world"

execute() resolves with the workflow's final output — equivalent to a POST /api/hello but with the path resolved automatically from the manifest.


4. Stream progress (SSE)

For workflows with LLM agents, MCP calls, or external API steps, stream each step as it completes:

const result = await client.execute("screen-candidate", {
  payload: { candidate_id: 42 },
  onProgress: (ev) => {
    console.log(`  ✔ ${ev.step_id} (${ev.kind}) — ${ev.duration_ms}ms`);
    // ev.snapshot is the full workflow context after this step
  },
});

console.log("Final output:", result);

Auto-detection

You do not need to set mode: "sse" explicitly. The SDK fetches the workflow manifest and switches to SSE automatically when onProgress is provided and the workflow contains slow steps (agent, mcp, api_call). For fast workflows it falls back to REST silently.


5. React example

import { useState } from "react";
import { TuvlClient, type StepEvent } from "@tuvl/client";

const client = new TuvlClient({
  baseUrl: import.meta.env.VITE_TUVL_URL,
  token: import.meta.env.VITE_TUVL_TOKEN,
});

export function ScreeningButton({ candidateId }: { candidateId: number }) {
  const [steps, setSteps] = useState<StepEvent[]>([]);
  const [result, setResult] = useState<unknown>(null);
  const [loading, setLoading] = useState(false);

  async function run() {
    setLoading(true);
    setSteps([]);

    const output = await client.execute("screen-candidate", {
      payload: { candidate_id: candidateId },
      onProgress: (ev) => setSteps((prev) => [...prev, ev]),
    });

    setResult(output);
    setLoading(false);
  }

  return (
    <div>
      <button onClick={run} disabled={loading}>
        {loading ? "Screening…" : "Screen Candidate"}
      </button>

      {steps.map((ev) => (
        <div key={ev.step_id}>
          {ev.step_id}  {ev.signal} ({ev.duration_ms}ms)
        </div>
      ))}

      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </div>
  );
}

6. Force a transport

// Always SSE regardless of workflow hints
await client.execute("hello", {
  payload: { message: "forced" },
  mode: "sse",
  onProgress: console.log,
});

// Always REST regardless of slow steps
await client.execute("screen-candidate", {
  payload: { candidate_id: 1 },
  mode: "rest",
});

7. Cancellation

const controller = new AbortController();

// Cancel after 10 s
const timeout = setTimeout(() => controller.abort(), 10_000);

try {
  await client.execute("long-workflow", {
    payload: {},
    signal: controller.signal,
    onProgress: (ev) => console.log(ev.step_id),
  });
} finally {
  clearTimeout(timeout);
}

Next steps

  • API Reference — complete method, type, and transport documentation
  • CLI: stream-watch — stream workflows from the terminal without writing any code