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,
},
})
}
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
| Task | How |
|---|---|
| Create provider | createOpenAI({ baseURL: .../agent/:id, headers: { authorization, x-oauth-access-token } }) |
| Target Responses API | provider.responses("agent") |
| Multi-turn | providerOptions: { openai: { previousResponseId } } |
Capture responseId | Read response.id from raw part where type === "response.completed" |
| Agent registry | JSON.parse(metadata["x_alien_agent_registry"]) — double-parse required |
| Subagent context | data-subagent / data-subagent-end chunks emitted by your raw handler |
| Resume | GET /agent/:id/responses/:respId?starting_after=<seq> + translateResponseStream |
See also
- Responses API —
previous_response_id, session storage, and response object shape. - Streaming Responses — full event type reference and
x_alien_*metadata keys. - Configure Your Workflow — the workflow graph the provider calls into.
- Vercel AI SDK reference: https://sdk.vercel.ai/docs/reference/ai-sdk-core/stream-text.