› cavi-ai/
GitHub ↗

Send streaming text to your application

Use a configured client from provider setup. Pass a callback that appends text to your UI, writes to an application stream, or updates a job. The helper below returns final text and the run ID only after a completed outcome. It borrows the client and accepts the caller's cancellation signal.

Download streamText.

ts
import { ApiClientError, ApiClientErrorCode, type CapabilityClient, type StreamRunBody } from "@cavi-ai/api-client";

export async function streamText(
  client: CapabilityClient,
  body: StreamRunBody,
  write: (text: string) => void,
  signal: AbortSignal,
  reportError?: (error: unknown) => void,
) {
  let text: string | undefined;
  let terminalText: string | undefined;
  let runError: string | undefined;
  let transportError: unknown;
  const result = await client.streamRun(body, {
    onEvent(event) {
      if (event.event === "message.delta") {
        text = (text ?? "") + event.delta;
        write(event.delta);
      }
      if (event.event === "run.completed") terminalText = event.output;
      if (event.event === "run.failed") runError = event.error;
    },
    onError(error) {
      transportError = error;
      reportError?.(error);
    },
  }, { signal });
  if (!result.ok) throw new ApiClientError(result.gap.note, {
    code: ApiClientErrorCode.RequestFailed, cause: result.gap,
  });
  if (result.data.outcome !== "completed") {
    const code = result.data.outcome === "failed" ? "run_failed"
      : result.data.outcome === "cancelled" ? "run_cancelled" : "run_incomplete";
    throw new ApiClientError(`Stream ended with outcome ${result.data.outcome ?? "unknown"}`, {
      type: "run", code, cause: { ...result.data, error: runError, transportError },
    });
  }
  const answer = terminalText ?? text;
  if (answer === undefined) {
    throw new ApiClientError("Stream completed without a text answer.", {
      type: "run", code: "run_output_missing", cause: result.data,
    });
  }
  return { ...result.data, text: answer };
}

Call it with your run body, output callback, and an AbortSignal. Use the optional reportError callback for server diagnostics; it must not throw. It observes parse/transport errors, including recoverable malformed frames. A run.failed event is captured separately. The typed exception retains the run ID, outcome, run error, and any transport error in cause; callers branch on run_failed, run_cancelled, run_incomplete, or run_output_missing. The example uses string values to also support releases without the corresponding enum aliases.

The returned text uses a terminal output snapshot when one is supplied, otherwise the accumulated deltas. Do not append a terminal snapshot as another delta: that can duplicate the answer. Partial deltas can already be visible when a run fails; retain them as partial output, not a successful answer.

Completion without an observed text delta or snapshot throws run_output_missing; the helper does not invent an empty answer. Dry-run completion does not supply a text answer. An explicitly supplied empty normalized snapshot remains a valid string. Provider normalization can omit empty native text: the pinned Codex adapter does so. For tool-only workflows, consume the events and outcome directly instead of requiring a text answer.

Run the streaming tests

Download streaming-tests.ts beside runtime-streaming.ts in examples/. Use the ESM workspace and package installation from consumer testing, then compile and run:

sh
npx tsc --target ES2022 --module NodeNext --moduleResolution NodeNext \
  --strict --skipLibCheck --types node --outDir .stream-tests \
  examples/streaming-tests.ts examples/runtime-streaming.ts
node --test .stream-tests/streaming-tests.js

Expected result: seven passing tests without provider credentials or network calls. Native Codex SSE fixtures exercise snapshots, deltas, missing output, failed/cancelled/incomplete streams, availability gaps, and authentication. A normalized event fixture checks explicit empty snapshots independently of provider normalization. Tests reuse the borrowed client and dispose it through the test cleanup hook. A failed stream is submitted once, without replay.

Use requireCompletedStream and requireStreamText when the release exports them and your application requires completed output. The development collector preserves facade gaps, handles text snapshots, and returns normalized usage when supplied by the terminal event. The implementation above also supports releases without these helpers.

Render more than text

EventApplication action
message.deltaAppend the delta
run.completedMark completion; replace with terminal text if supplied
run.failedMark partial output failed and show an application error
run.cancelledMark cancellation
approval.requestPresent the available choices; use the provider's approval API
tool.call.started/completed/failedUpdate tool progress

The event vocabulary is shared; a provider need not emit every event. Receiving an approval event does not supply a universal approval-submission API.

Distinguish call and execution outcomes

  • ok: false: the stream call failed or was unavailable; inspect its gap.
  • ok: true: inspect data.outcome, which can still be failed or cancelled.
  • outcome: null: no terminal event was captured; do not display completion.

Authentication and unclassified failures still reject. onError reports transport/parsing problems; run.failed arrives via onEvent.

Gateway prerequisites and cancellation

Hermes requires a gateway sessionKey in the body and bridges SSE run events. OpenClaw bridges native WebSocket events. The facade cannot supply missing permissions, origin configuration, or session identity.

Pass a caller signal as the third streamRun argument. Caller abort returns a request-aborted gap and requests best-effort cancellation when a run ID is known. Reconcile upstream state when your workflow requires confirmation. Do not replay a streamed write automatically after connection loss.

Dispose at owner shutdown. On a raw RuntimeClient, check advertised streaming support and method presence; raw gateways commonly expose subscriptions instead of inline streamRun.

Errors · Requests · Runtime operations

Referenced examples

Verified example · TypeScript

Runtime streaming

examples/runtime-streaming.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import { ApiClientError, ApiClientErrorCode, type CapabilityClient, type StreamRunBody } from "@cavi-ai/api-client";

export async function streamText(
  client: CapabilityClient,
  body: StreamRunBody,
  write: (text: string) => void,
  signal: AbortSignal,
  reportError?: (error: unknown) => void,
) {
  let text: string | undefined;
  let terminalText: string | undefined;
  let runError: string | undefined;
  let transportError: unknown;
  const result = await client.streamRun(body, {
    onEvent(event) {
      if (event.event === "message.delta") {
        text = (text ?? "") + event.delta;
        write(event.delta);
      }
      if (event.event === "run.completed") terminalText = event.output;
      if (event.event === "run.failed") runError = event.error;
    },
    onError(error) {
      transportError = error;
      reportError?.(error);
    },
  }, { signal });
  if (!result.ok) throw new ApiClientError(result.gap.note, {
    code: ApiClientErrorCode.RequestFailed, cause: result.gap,
  });
  if (result.data.outcome !== "completed") {
    const code = result.data.outcome === "failed" ? "run_failed"
      : result.data.outcome === "cancelled" ? "run_cancelled" : "run_incomplete";
    throw new ApiClientError(`Stream ended with outcome ${result.data.outcome ?? "unknown"}`, {
      type: "run", code, cause: { ...result.data, error: runError, transportError },
    });
  }
  const answer = terminalText ?? text;
  if (answer === undefined) {
    throw new ApiClientError("Stream completed without a text answer.", {
      type: "run", code: "run_output_missing", cause: result.data,
    });
  }
  return { ...result.data, text: answer };
}

Package imports

Referenced by

Artifact provenance
Package
@cavi-ai/api-client@0.21.0
Release
v0.21.0
Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1
Source
examples/runtime-streaming.ts
Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558
Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48

Verified example · TypeScript

Streaming tests

examples/streaming-tests.ts

@cavi-ai/api-clientv0.21.007f0ee45
TypeScript · complete source
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
import { createApiClient, createCapabilityClient, createRuntimeProviderRegistry, isApiClientError, isAuthError } from "@cavi-ai/api-client";
import { createCodexProviderModule } from "@cavi-ai/api-client/providers/codex/runtime";
import { streamText } from "./runtime-streaming.js";

function fixture(t: TestContext, frames: [string, unknown][], status = 200) {
  let requests = 0;
  const client = createApiClient("codex", {
    registry: createRuntimeProviderRegistry({ modules: [createCodexProviderModule({ apiKey: "fixture-key" })] }),
    fetchImpl: async (_url, init) => {
      requests += 1;
      assert.equal(init?.method, "POST");
      assert.equal(JSON.parse(String(init?.body)).stream, true);
      if (status !== 200) return Response.json({}, { status });
      const events: [string, unknown][] = [["response.created", { response: { id: "stream-1" } }], ...frames];
      return new Response(events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join(""), {
        headers: { "Content-Type": "text/event-stream" },
      });
    },
  });
  t.after(() => client.dispose());
  let partial = "";
  const collect = () => streamText(client, { model: "server-model", input: "Question" },
    (delta) => { partial += delta; }, new AbortController().signal);
  return { collect, requests: () => requests, partial: () => partial };
}

test("uses the final snapshot without appending it as a duplicate delta", async (t) => {
  const app = fixture(t, [
    ["response.output_text.delta", { delta: "Partial" }],
    ["response.completed", { response: { output_text: "Final answer" } }],
  ]);
  assert.equal((await app.collect()).text, "Final answer");
  assert.equal(app.partial(), "Partial");
  assert.equal((await app.collect()).text, "Final answer", "the helper must leave its borrowed client usable");
  assert.equal(app.requests(), 2);
});

test("uses observed deltas when completion has no snapshot", async (t) => {
  const app = fixture(t, [
    ["response.output_text.delta", { delta: "An " }],
    ["response.output_text.delta", { delta: "answer" }],
    ["response.completed", { response: {} }],
  ]);
  const answer = await app.collect();
  assert.equal(answer.outcome, "completed");
  assert.equal(answer.text, "An answer");
  assert.equal(answer.runId, "stream-1");
});

test("preserves an explicitly supplied empty normalized snapshot", async (t) => {
  // The pinned Codex adapter omits empty native text. Exercise the shared
  // contract directly for adapters that supply an explicit empty snapshot.
  const client = createCapabilityClient({ providerKind: "fixture", fallbackSupports: { streaming: true }, runtime: {
    getRuntimeCapabilities: async () => ({ providerKind: "fixture", supports: { streaming: true } }),
    startRun: async () => ({ run_id: "unused", status: "completed" }),
    streamRun: async (_body, handlers) => {
      handlers.onEvent({ event: "message.delta", runId: "stream-1", delta: "Partial" });
      handlers.onEvent({ event: "run.completed", runId: "stream-1", output: "" });
    },
  } });
  t.after(() => client.dispose());
  const answer = await streamText(client, { input: "Question" }, () => {}, new AbortController().signal);
  assert.equal(answer.text, "");
});

test("rejects completion with no observed text rather than inventing an empty answer", async (t) => {
  for (const response of [{}]) {
    const app = fixture(t, [["response.completed", { response }]]);
    await assert.rejects(app.collect(), (error) => {
      assert.ok(isApiClientError(error));
      assert.equal(error.type, "run");
      assert.equal(error.code, "run_output_missing");
      assert.equal((error.cause as { runId: string }).runId, "stream-1");
      return true;
    });
  }
});

test("keeps partial output failed, cancelled, or incomplete", async (t) => {
  for (const [event, code] of [["response.failed", "run_failed"], ["response.cancelled", "run_cancelled"], [null, "run_incomplete"]] as const) {
    const frames: [string, unknown][] = [["response.output_text.delta", { delta: "Partial" }]];
    if (event) frames.push([event, { response: { error: { message: "Private execution diagnostic" } } }]);
    const app = fixture(t, frames);
    await assert.rejects(app.collect(), (error) => {
      assert.ok(isApiClientError(error));
      assert.equal(error.code, code);
      assert.equal((error.cause as { runId: string }).runId, "stream-1");
      return true;
    });
    assert.equal(app.partial(), "Partial");
    assert.equal(app.requests(), 1, "a failed stream must not be replayed automatically");
  }
});

test("retains the original availability gap in the typed error", async (t) => {
  const app = fixture(t, [], 503);
  await assert.rejects(app.collect(), (error) => {
    assert.ok(isApiClientError(error));
    assert.equal(error.code, "request_failed");
    assert.equal((error.cause as { reason: string }).reason, "backend-unavailable");
    return true;
  });
  assert.equal(app.requests(), 1);
});

test("keeps provider authentication failures as exceptions", async (t) => {
  const app = fixture(t, [], 401);
  await assert.rejects(app.collect(), isAuthError);
  assert.equal(app.requests(), 1);
});

Package imports

Referenced by

Artifact provenance
Package
@cavi-ai/api-client@0.21.0
Release
v0.21.0
Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1
Source
examples/streaming-tests.ts
Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558
Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48