Skip to main content

Vercel AI SDK

The Alien platform is fully compatible with the Vercel AI SDK (ai ≥ 6) via @ai-sdk/openai's OpenAI Responses provider. This page shows how to wire up the provider, stream responses with platform extensions, handle multi-turn conversations, and build a complete Next.js App Router chat route — based on the reference implementation in lds-chatbot.

Dependencies

npm install ai @ai-sdk/openai
# or
yarn add ai @ai-sdk/openai

The reference app uses:

{
"ai": "^6.0.168",
"@ai-sdk/openai": "^3.0.53"
}

Creating the provider

The platform exposes the Responses API at /agent/:workflowId/responses. Point createOpenAI at that base URL and pass your access token in both auth headers — the platform accepts either Authorization: Bearer (API tokens starting with oat_) or x-oauth-access-token (OAuth JWT tokens), so sending both ensures compatibility regardless of token type.

import { createOpenAI } from "@ai-sdk/openai"

function platformProvider(workflowId: number, accessToken: string) {
return createOpenAI({
baseURL: `https://api.alien.club/agent/${workflowId}`,
apiKey: "unused", // field is required by the SDK but ignored by the platform
headers: {
"authorization": `Bearer ${accessToken}`,
"x-oauth-access-token": accessToken,
},
})
}
note

Pass apiKey: "unused" (or any non-empty string) — the SDK requires the field but the platform authenticates via the custom headers above, not via the OpenAI API key mechanism.

Basic single-turn stream

Use provider.responses("agent") to target the Responses API endpoint. The "agent" string is the model name forwarded in the request body — the platform ignores it and uses the model configured on the workflow.

import { streamText } from "ai"

const provider = platformProvider(workflowId, accessToken)

const result = await streamText({
model: provider.responses("agent"),
prompt: "What is the SYNTEC collective agreement?",
})

for await (const chunk of result.textStream) {
process.stdout.write(chunk)
}

Multi-turn with previousResponseId

The Responses API maintains session state server-side. Pass the id from the previous response as previousResponseId to chain turns — no need to resend conversation history.

const result = await streamText({
model: provider.responses("agent"),
prompt: "What is the trial period for an executive?",
providerOptions: {
openai: { previousResponseId: "resp_KMen5Xw2..." },
},
})

// Read the new response ID for the next turn
for await (const _ of result.textStream) { /* consume stream */ }

const finalResponse = await result.experimental_providerMetadata
const nextResponseId = (finalResponse?.openai as { responseId?: string })?.responseId

For a cleaner pattern, use includeRawChunks: true to intercept the response.completed event directly (see Platform extension chunks below).

Next.js App Router — complete chat route

This is the full pattern used in lds-chatbot. It streams the response to the client while persisting the message and capturing the responseId in the background.

// app/api/chat/route.ts
import { createOpenAI } from "@ai-sdk/openai"
import { streamText } from "ai"
import { createUIMessageStream, createUIMessageStreamResponse } from "ai"

export async function POST(request: Request) {
const { workflowId, message, previousResponseId, accessToken } = await request.json()

const provider = createOpenAI({
baseURL: `https://api.alien.club/agent/${workflowId}`,
apiKey: "unused",
headers: {
"authorization": `Bearer ${accessToken}`,
"x-oauth-access-token": accessToken,
},
})

// Holds the response ID captured during the stream — read after the stream drains
let capturedResponseId: string | undefined

const stream = createUIMessageStream({
execute: async (writer) => {
const result = await streamText({
model: provider.responses("agent"),
prompt: message,
providerOptions: previousResponseId
? { openai: { previousResponseId } }
: undefined,
includeRawChunks: true,
// Optional: smooth word-by-word delivery
// experimental_transform: smoothStream({ delayInMs: 20, chunking: "word" }),
})

// Emit a stable conversationId chunk so the client can track the session
// before any text arrives
writer.write({ type: "data-conversationId", value: { conversationId: previousResponseId ?? "pending" } })

for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta":
writer.write({ type: "text-delta", textDelta: part.textDelta })
break

case "raw": {
// Extract responseId and other platform metadata from raw Responses API events
const raw = part.rawValue as Record<string, unknown>
const eventType = raw?.type as string | undefined

if (eventType === "response.completed") {
const response = raw?.response as Record<string, unknown> | undefined
capturedResponseId = response?.id as string | undefined
}
break
}
}
}
},
})

// Tee: one branch goes to the client, the other to background persistence
const [forClient, forPersist] = stream.tee()

// Background: drain forPersist and save to DB
void persistResponse(forPersist, capturedResponseId, workflowId)

return createUIMessageStreamResponse({ stream: forClient })
}

async function persistResponse(stream: ReadableStream, responseId: string | undefined, workflowId: number) {
// Drain the stream and save the assistant message + responseId to your database
// ...
}

Platform extension chunks

When you pass includeRawChunks: true, the raw Responses API events are exposed as part.type === "raw" in fullStream. The platform's reference implementation (responses_stream.ts) emits additional data-* chunks from these raw events to carry multi-agent context to the client.

data-conversationId

Emitted once at the start of the stream, before any text. Carries the session identifier for this turn.

writer.write({
type: "data-conversationId",
value: { conversationId: string },
})

On the client, read it via useChat's data field and store it — pass it as previousResponseId on the next turn to maintain the session.

data-subagent

Emitted when a subagent becomes active in a multi-agent run. Resolved from response.metadata.x_alien_agent_registry and the per-item agent:<id>:: prefix in output item IDs.

writer.write({
type: "data-subagent",
value: {
id: "subagent-6", // node id in the workflow
name: "Légifrance researcher",
kind: "subagent", // "main" | "subagent" | "tool"
parentId: "MAIN",
},
})

data-subagent-end

Emitted with no value when the root agent resumes after a subagent's turn finishes. Use it to close a subagent panel in your UI.

writer.write({ type: "data-subagent-end", value: undefined })

data-toolCall

Emitted on each tool invocation.

writer.write({
type: "data-toolCall",
value: {
id: "call_abc",
name: "search_legal_database",
args: { query: "SYNTEC trial period" },
},
})

data-streamProgress

Emitted on every raw Responses API event. Carries the sequence_number for resume and a terminal flag. Mark it transient: true so the AI SDK discards it and doesn't accumulate it in memory.

writer.write({
type: "data-streamProgress",
value: {
responseId: "resp_...",
sequenceNumber: 7,
terminal: false,
},
transient: true,
})

Store the latest sequenceNumber client-side. If the connection drops, reconnect via GET /agent/:id/responses/<responseId>?starting_after=<sequenceNumber> (see Resume).

Parsing x_alien_agent_registry

The agent registry arrives as a JSON-encoded string inside response.metadata. Double-parse it to get the array:

if (eventType === "response.created") {
const response = raw?.response as Record<string, unknown>
const metadata = response?.metadata as Record<string, string> | undefined

const registryRaw = metadata?.["x_alien_agent_registry"]
if (registryRaw) {
const registry: Array<{ id: string; kind: string; name: string; parent_id: string | null }> =
JSON.parse(registryRaw)
// build a name-lookup map for subagent display names
}

const rootAgentId = metadata?.["x_alien_root_agent_id"] // typically "MAIN"
}

Reading extension data in the client

Use useChat from @ai-sdk/react. Data chunks written via writer.write({ type: "data-*", value: ... }) arrive in the data array returned by the hook.

"use client"
import { useChat } from "@ai-sdk/react"

export function ChatPanel({ workflowId }: { workflowId: number }) {
const { messages, data, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat",
body: { workflowId },
})

// Read the platform conversationId from the data stream
const conversationId = data?.findLast(
(d): d is { conversationId: string } =>
typeof d === "object" && d !== null && "conversationId" in d
)?.conversationId

// Active subagent (if any)
const activeSubagent = data?.findLast(
(d): d is { id: string; name: string } =>
typeof d === "object" && d !== null && "id" in d && "name" in d
)

return (
<div>
{activeSubagent && <p>Subagent active: {activeSubagent.name}</p>}

{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}

<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">Send</button>
</form>
</div>
)
}

Resume after a network drop

If the SSE connection drops, use the last sequenceNumber from data-streamProgress to reconnect without restarting the agent run.

Server — resume route:

// app/api/chat/resume/route.ts
import { translateResponseStream } from "@/lib/platform/responses_stream"

export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const responseId = searchParams.get("responseId")!
const workflowId = searchParams.get("workflowId")!
const startingAfter = parseInt(searchParams.get("startingAfter") ?? "0", 10)

const platformResponse = await fetch(
`https://api.alien.club/agent/${workflowId}/responses/${responseId}?starting_after=${startingAfter}`,
{ headers: { authorization: `Bearer ${accessToken}` } },
)

// translateResponseStream converts the raw Responses API SSE into AI SDK UI chunks
const stream = translateResponseStream(platformResponse.body!, {
workflowId: Number(workflowId),
accessToken,
subagentNames: new Map(),
})

return createUIMessageStreamResponse({ stream })
}

Client:

const { data, reload } = useChat({ api: "/api/chat" })

// On network error, reconnect via the resume endpoint
async function onConnectionError() {
const progress = data?.findLast(
(d): d is { responseId: string; sequenceNumber: number } =>
typeof d === "object" && d !== null && "sequenceNumber" in d
)

if (progress) {
await fetch(
`/api/chat/resume?responseId=${progress.responseId}&startingAfter=${progress.sequenceNumber}`
)
}
}

SidecarState — full implementation reference

The responses_stream.ts file in lds-chatbot contains a complete production implementation of all of the above patterns. It handles:

  • Registry parsing from response.metadata.x_alien_agent_registry
  • Per-item agent attribution from agent:<id>:: item ID prefixes
  • Subagent activation and deactivation signals
  • Tool call forwarding
  • TTFT timing measurement
  • Stream drain and result capture (responseId, usage, ok)

Copy and adapt lib/platform/responses_stream.ts as the starting point for your own integration rather than reimplementing from scratch.

Summary

TaskHow
Create providercreateOpenAI({ baseURL: .../agent/:id, headers: { authorization, x-oauth-access-token } })
Target Responses APIprovider.responses("agent")
Multi-turnproviderOptions: { openai: { previousResponseId } }
Capture responseIdRead response.id from raw part where type === "response.completed"
Agent registryJSON.parse(metadata["x_alien_agent_registry"]) — double-parse required
Subagent contextdata-subagent / data-subagent-end chunks emitted by your raw handler
ResumeGET /agent/:id/responses/:respId?starting_after=<seq> + translateResponseStream

See also