OpenAI Assistants logo
PythonJavaScript

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:

  1. queued → in_progress — the assistant is processing
  2. requires_action — the model wants to call a function; your code must handle it and submit the result back
  3. completed — done; read the new messages from the thread
  4. 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