ChatKit Workflow Agent
This integration runs an OpenAI ChatKit workflow and returns the assistant's text. Use it to evaluate a workflow you have built in OpenAI's Agent Builder rather than a single model call.
Required secrets
| Secret | What it is |
|---|---|
OPENAI_API_KEY | API key for OpenAI and ChatKit requests. |
CHATKIT_WORKFLOW_ID | Workflow id to execute through ChatKit. |
Where to get the credentials
Create an API key in your OpenAI account under API keys for OPENAI_API_KEY.
The CHATKIT_WORKFLOW_ID is the id of the workflow you publish in OpenAI's Agent
Builder. Store both as project secrets.
Turn support
Multi-turn. The agent keeps a conversation thread alive across turns within an evaluation session, so it suits multi-turn evaluation.
Limitations and notes
- You must publish a ChatKit workflow first and supply its id.
- Calls are billed to your own OpenAI account, subject to its quotas and rate limits.
How the agent works
The copied agent is a single JavaScript function. This walkthrough shows the pattern so you can adapt it for a custom agent of your own. Unlike a single model call, this one is multi-turn: it opens a ChatKit session, drives a conversation endpoint that streams Server-Sent Events, and keeps a thread alive across turns.
The entry point is process, which receives input (the evaluation case text)
and reads its secrets from the env object by name:
export async function process(input) {
try {
const apiKey = String(env.OPENAI_API_KEY ?? "").trim();
const parsed = parseInput(input);
const workflowId =
parsed.workflowId || String(env.CHATKIT_WORKFLOW_ID ?? "").trim();
// ...
The two secrets are env.OPENAI_API_KEY and env.CHATKIT_WORKFLOW_ID (a JSON
input may override the workflow id). Both are listed in Required
secrets above.
Input is normalized so it accepts either plain text or a JSON object. A JSON
object can carry the prompt under text, prompt, or input, plus optional
user, workflow_id, and workflow_version fields; plain text is used
verbatim:
const parseInput = (input) => {
const raw = String(input ?? "").trim();
// ...
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return {
text: String(parsed.text ?? parsed.prompt ?? parsed.input ?? "").trim(),
user: String(parsed.user ?? DEFAULT_USER).trim() || DEFAULT_USER,
workflowId: String(parsed.workflow_id ?? parsed.workflowId ?? "").trim(),
// ...
};
}
// ...
};
Creating a session
The first request exchanges the API key for a short-lived client_secret. It
posts the user and workflow to the session endpoint with a Bearer token and the
ChatKit beta header:
const response = await fetch(SESSION_ENDPOINT, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"openai-beta": "chatkit_beta=v1",
},
body: JSON.stringify({
user,
workflow: { id: workflowId, version: workflowVersion },
}),
});
// ...
const clientSecret =
typeof payload?.client_secret === "string" ? payload.client_secret : "";
Thread creation and reuse across turns
A module-level activeThreadId tracks the conversation. On the first turn it is
empty, so the payload type is threads.create; on later turns it is set, so the
payload reuses the thread with threads.add_user_message:
let activeThreadId = "";
const buildConversationPayload = ({ text }) => {
const input = { content: [{ type: "input_text", text }], /* ... */ };
if (activeThreadId) {
return {
type: "threads.add_user_message",
params: { input, thread_id: activeThreadId },
};
}
return { type: "threads.create", params: { input } };
};
The conversation request authenticates with the client_secret (not the API
key) and asks for an event stream:
const conversationResponse = await fetch(CONVERSATION_ENDPOINT, {
method: "POST",
headers: {
authorization: `Bearer ${clientSecret}`,
"content-type": "application/json",
accept: "text/event-stream",
// ...
},
body: JSON.stringify(buildConversationPayload(parsed)),
});
Parsing the streamed events
The whole stream is read as text and split into Server-Sent Events. Each event is
a block separated by a blank line; the data: lines are joined and parsed as
JSON:
const parseSseEvents = (responseText) => {
const events = [];
for (const chunk of String(responseText ?? "").split("\n\n")) {
const dataLines = chunk
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.filter(Boolean);
// ...
events.push(JSON.parse(dataLines.join("\n").trim()));
}
return events;
};
The assistant text is pulled out of those events. A completed
assistant_message carries the full output_text; otherwise the agent
accumulates streamed text_delta updates:
const extractAssistantText = (events) => {
let text = "";
for (const event of events) {
if (
event?.type === "thread.item.done" &&
event?.item?.type === "assistant_message"
) {
// ... join the output_text parts and return them
}
if (
event?.type === "thread.item.updated" &&
event?.update?.type === "assistant_message.content_part.text_delta" &&
typeof event.update.delta === "string"
) {
text += event.update.delta;
}
}
return text.trim();
};
After extracting the text, the agent also reads the thread id out of the events
and stores it in activeThreadId so the next turn reuses the same thread:
const threadId = extractThreadId(events);
if (threadId) {
activeThreadId = threadId;
}
Error handling
This agent returns plain strings rather than throwing. Missing input returns
"missing_required_input", failures return a colon-joined code such as
chatkit_conversation_failed: ..., and on success it returns the assistant text
directly:
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
Adapting it for your own agent
To point the same skeleton at a different service, change:
- the
SESSION_ENDPOINTandCONVERSATION_ENDPOINTURLs, - the auth headers (here a
BearerAPI key for the session, then aBearerclient_secretfor the conversation), - the request body shapes for creating and continuing a conversation,
- how you extract the reply and the conversation/thread id from the response (the SSE parsing here is specific to ChatKit's event types), and
- the secret keys you read from
env(and declare them in the required-secrets table).
Set it up
- Copy this integration from the registry — see Registry integrations.
- Create the project secrets above — see Project secrets.
- Preview the agent to confirm it works.