OpenAI Assistants Integration
Gate actions in OpenAI function calling and Assistants API with human sign-off.
OpenAI provides two ways to use function calling: the Chat Completions API (stateless, simpler) and the Assistants API (stateful, persistent threads). This guide covers both. Most people start with Chat Completions — the Assistants API adds thread management and a more complex run lifecycle, which is worth it when you need multi-turn conversation history.
How the Assistants API run lifecycle works
Unlike Chat Completions, the Assistants API is asynchronous. When you start a run, it moves through states you need to handle:
- queued → in_progress — the assistant is processing
- requires_action — the model wants to call a function; your code must handle it and submit the result back
- completed — done; read the new messages from the thread
- expired — you didn't submit tool outputs before the ~10 minute deadline; the run is dead
The critical difference from Chat Completions: if you don't submit tool outputs in time, the run fails. For approvals that could take a while, see the callback tip at the bottom.
Installation
pip install openai requests
Pattern 1: Chat Completions (simpler, stateless)
Use this when you don't need persistent conversation history. Define a tools list, run a loop, handle tool calls as they come.
Step 1: Define the function tool
tools = [{
"type": "function",
"function": {
"name": "request_human_approval",
"description": (
"Request human approval before taking a sensitive or irreversible action. "
"Use for financial transactions, external emails, data deletion, "
"deployments, or anything the user should review first."
),
"strict": True,
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {
"action": {"type": "string", "description": "What the agent wants to do — be specific"},
"context": {"type": "string", "description": "Why, and any relevant background"},
"urgency": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
},
"required": ["action", "context", "urgency"],
},
},
}]
Step 2: Run the agent loop
import openai, requests, time, json
client = openai.OpenAI()
HANDOVER_KEY = "ho_your_key_here"
APPROVER_EMAIL = "approver@yourcompany.com"
HEADERS = {"Authorization": f"Bearer {HANDOVER_KEY}"}
def call_handover(action: str, context: str, urgency: str = "medium") -> str:
# Create the decision — approver gets an email with Approve/Deny buttons
resp = requests.post("https://thehandover.xyz/decisions", headers=HEADERS, json={
"action": action, "context": context, "urgency": urgency,
"approver": APPROVER_EMAIL,
})
did = resp.json()["id"]
# Poll every 5 seconds for up to 10 minutes
for _ in range(120):
time.sleep(5)
r = requests.get(f"https://thehandover.xyz/decisions/{did}", headers=HEADERS).json()
if r["status"] != "pending":
notes = r.get("response_notes") or "none"
return f"Decision: {r['status']}. Approver notes: {notes}"
return "Approval timed out. Do not proceed."
def run_agent(user_message: str) -> str:
messages = [
{"role": "system", "content": "You are a helpful assistant. Before any financial transaction, customer email, or data deletion, you MUST call request_human_approval first."},
{"role": "user", "content": user_message},
]
while True:
resp = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = call_handover(**args)
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
print(run_agent("Issue a €340 refund to customer #4821."))
Pattern 2: Assistants API (stateful, persistent threads)
Use this when you need OpenAI to manage conversation history, or when building multi-turn agents. The key difference from Chat Completions: everything is async — you create a run, poll its status, and submit tool outputs back to the run.
Create your assistant once and save the ID (e.g. asst_abc123). You don't recreate it on every request — in production, store it as an environment variable and retrieve it by ID.
Step 1: Create the assistant (run once)
import openai
client = openai.OpenAI()
assistant = client.beta.assistants.create(
name="Approval-gated assistant",
instructions=(
"You are a helpful assistant. Before any financial transaction, "
"customer email, or data deletion, you MUST call request_human_approval first."
),
model="gpt-4o",
tools=[{
"type": "function",
"function": {
"name": "request_human_approval",
"description": "Request human approval before a sensitive or irreversible action.",
"strict": True, # enforces the schema exactly — recommended for reliability
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {
"action": {"type": "string", "description": "What you want to do — be specific"},
"context": {"type": "string", "description": "Why, and any relevant background"},
"urgency": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
},
"required": ["action", "context", "urgency"],
},
},
}],
)
print(f"Assistant ID: {assistant.id}") # save this — e.g. asst_abc123
Step 2: Handle a conversation
Each conversation gets its own thread. Create the thread, add the user's message, start a run, then poll — handling requires_action when the model calls the function.
import time, json, requests
ASSISTANT_ID = "asst_abc123" # from Step 1
HANDOVER_KEY = "ho_your_key_here"
APPROVER_EMAIL = "approver@yourcompany.com"
HEADERS = {"Authorization": f"Bearer {HANDOVER_KEY}"}
def call_handover(action: str, context: str, urgency: str = "medium") -> str:
resp = requests.post("https://thehandover.xyz/decisions", headers=HEADERS, json={
"action": action, "context": context, "urgency": urgency,
"approver": APPROVER_EMAIL,
})
did = resp.json()["id"]
for _ in range(120):
time.sleep(5)
r = requests.get(f"https://thehandover.xyz/decisions/{did}", headers=HEADERS).json()
if r["status"] != "pending":
return f"Decision: {r['status']}. Notes: {r.get('response_notes') or 'none'}"
return "Timed out. Do not proceed."
def run_conversation(user_message: str) -> str:
# 1. Create a thread and add the user's message
thread = client.beta.threads.create()
client.beta.threads.messages.create(
thread_id=thread.id, role="user", content=user_message
)
# 2. Start the run
run = client.beta.threads.runs.create(
thread_id=thread.id, assistant_id=ASSISTANT_ID
)
# 3. Poll — handle requires_action when the model calls the function
while run.status in ("queued", "in_progress", "requires_action"):
time.sleep(1)
run = client.beta.threads.runs.retrieve(thread_id=thread.id, run_id=run.id)
if run.status == "requires_action":
tool_outputs = []
for tc in run.required_action.submit_tool_outputs.tool_calls:
args = json.loads(tc.function.arguments)
result = call_handover(**args)
tool_outputs.append({"tool_call_id": tc.id, "output": result})
# Submit all outputs at once — the run resumes
run = client.beta.threads.runs.submit_tool_outputs(
thread_id=thread.id, run_id=run.id, tool_outputs=tool_outputs
)
if run.status != "completed":
raise RuntimeError(f"Run ended with status: {run.status}")
# 4. Read the assistant's reply (newest message first)
messages = client.beta.threads.messages.list(thread_id=thread.id, order="desc")
return messages.data[0].content[0].text.value
print(run_conversation("Issue a €340 refund to customer #4821."))
Tool outputs must be submitted before the run expires (~10 minutes from when it entered requires_action). If your approval could take longer, use a callback URL — see the production tip below.
JavaScript / TypeScript
Installation
npm install openai
Chat Completions
import OpenAI from "openai";
const client = new OpenAI();
const HANDOVER_KEY = "ho_your_key_here";
const APPROVER_EMAIL = "approver@yourcompany.com";
const tools: OpenAI.Chat.ChatCompletionTool[] = [{
type: "function",
function: {
name: "request_human_approval",
description: "Request human approval before a sensitive or irreversible action.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
action: { type: "string", description: "What you want to do — be specific" },
context: { type: "string", description: "Why, and any relevant background" },
urgency: { type: "string", enum: ["low", "medium", "high", "critical"] },
},
required: ["action", "context", "urgency"],
},
},
}];
async function callHandover(action: string, context: string, urgency = "medium") {
const resp = await fetch("https://thehandover.xyz/decisions", {
method: "POST",
headers: { "Authorization": `Bearer ${HANDOVER_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ action, context, urgency, approver: APPROVER_EMAIL }),
});
const { id } = await resp.json();
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 5000));
const r = await (await fetch(`https://thehandover.xyz/decisions/${id}`, {
headers: { "Authorization": `Bearer ${HANDOVER_KEY}` },
})).json();
if (r.status !== "pending") return `Decision: ${r.status}. Notes: ${r.response_notes ?? "none"}`;
}
return "Timed out. Do not proceed.";
}
async function runAgent(userMessage: string) {
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: "Before financial transactions, customer emails, or data deletion, call request_human_approval." },
{ role: "user", content: userMessage },
];
while (true) {
const resp = await client.chat.completions.create({ model: "gpt-4o", messages, tools });
const msg = resp.choices[0].message;
messages.push(msg);
if (!msg.tool_calls) return msg.content;
for (const tc of msg.tool_calls) {
const args = JSON.parse(tc.function.arguments);
const result = await callHandover(args.action, args.context, args.urgency);
messages.push({ role: "tool", tool_call_id: tc.id, content: result });
}
}
}
Assistants API
import OpenAI from "openai";
const client = new OpenAI();
const ASSISTANT_ID = "asst_abc123"; // created once — store in env
async function runConversation(userMessage: string) {
// 1. Create a thread and add the message
const thread = await client.beta.threads.create();
await client.beta.threads.messages.create(thread.id, { role: "user", content: userMessage });
// 2. Start the run
let run = await client.beta.threads.runs.create(thread.id, { assistant_id: ASSISTANT_ID });
// 3. Poll and handle requires_action
while (["queued", "in_progress", "requires_action"].includes(run.status)) {
await new Promise(r => setTimeout(r, 1000));
run = await client.beta.threads.runs.retrieve(thread.id, run.id);
if (run.status === "requires_action") {
const toolOutputs = await Promise.all(
run.required_action!.submit_tool_outputs.tool_calls.map(async tc => {
const args = JSON.parse(tc.function.arguments);
const output = await callHandover(args.action, args.context, args.urgency);
return { tool_call_id: tc.id, output };
})
);
run = await client.beta.threads.runs.submitToolOutputs(
thread.id, run.id, { tool_outputs: toolOutputs }
);
}
}
if (run.status !== "completed") throw new Error(`Run ended: ${run.status}`);
// 4. Read the reply
const messages = await client.beta.threads.messages.list(thread.id, { order: "desc" });
const content = messages.data[0].content[0];
return content.type === "text" ? content.text.value : "[non-text response]";
}
Writing an effective system prompt
For Chat Completions, pass it as a system message. For Assistants, set it in the instructions field when creating. Either way, vague instructions get skipped — be explicit:
- Name specific actions: "any refund", "any email to a customer", "any DELETE operation"
- Set thresholds: "any transaction over €100" not "large transactions"
- Use MUST: "you MUST call request_human_approval" is stronger than "you should"
- Close loopholes: "including store credit, vouchers, or any compensation" if the model might find workarounds
Production tip: use a callback instead of polling
For the Assistants API in particular, polling inside the tool is risky — if the approver takes more than ~10 minutes, the run expires. Instead, pass a callback_url when creating the decision:
requests.post("https://thehandover.xyz/decisions", headers=HEADERS, json={
"action": action, "context": context, "urgency": urgency,
"approver": APPROVER_EMAIL,
"callback_url": "https://your-server.com/webhook/handover",
})
Your webhook fires the instant the approver clicks. For Assistants, store the thread_id and run_id before returning, then call submit_tool_outputs from the webhook handler when the result arrives. See the webhooks docs for the payload format.
Ready to add human oversight?
Free to start. No credit card required. Five minutes to integrate.
Get Started Free