Assistants, threads and runs
The stateful agent model is Ringside's OpenAI-Assistants-compatible layer for multi-turn agents that keep their own conversation history and call tools. An assistant is reusable config. A thread holds the messages. A run executes one against the other, pausing mid-flight when the model wants you to run a function and resuming when you submit the result. This page covers the object model, every lifecycle state, the function-calling loop, file_search RAG inside a run and the failure modes you will hit in production.
Why this exists
Plain chat completions are stateless. You send the full message list on every call and you own the history. That is fine for one-shot work and it is the right tool most of the time. The stateful model handles the other case. You get a long-lived agent that holds a growing conversation, calls tools across many turns and stays addressable by a stable id, so a browser, a worker or a webhook handler can all drive the same session.
Ringside implements the OpenAI Assistants object model on the wire so the official OpenAI SDKs drive it unchanged. Point base_url at https://api.fightclub.pro/v1, send your ko_ key as Authorization: Bearer, prefix your model with fc: and client.beta.assistants, client.beta.threads and client.beta.threads.runs work as written. Under the hood a thread is a thin alias over a Ringside conversation, so the same spend attribution, customer isolation and metering you get on chat completions apply to every run.
- Assistant
- Reusable, stateless config. Holds the model, system instructions, the tools the agent may call, tool_resources (e.g. vector stores), sampling knobs and metadata. It carries no conversation. Id prefix
asst_. - Thread
- A message container. Append user and assistant messages to it; it grows up to a hard cap of 5,000 messages. Carries metadata that attributes its spend to a customer. Id prefix
thread_. - Run
- One execution of an assistant against a thread. The run reads the thread, calls the model, dispatches tools and writes the model's replies back as new thread messages. It moves through a lifecycle and may pause for you to supply tool outputs. Id prefix
run_.
assistant (asst_) thread (thread_)
reusable config + message history
\ /
\ /
v v
run (run_)
executes one on the other,
appends replies to the threadAssistants
Create an assistant once and reuse it across many threads. The config is the contract for every run that names it.
| Field | Type | Notes |
|---|---|---|
| model | string (required) | A Ringside model ref. Must carry a prefix fc:openai/gpt-4o-mini, match:anthropic/sonnet>=4, slot:<alias> or dyn:<name>. A bare name like gpt-4o returns 400 invalid_model. The ref is validated at create time and again per run. |
| name | string | null | Unique among your active assistants. Reusing the name of an active assistant returns 409 assistant_name_in_use. Archived assistants do not block name reuse. |
| description | string | null | Free text, your own use. |
| instructions | string | null | The system prompt applied to every run, unless a run overrides it. |
| tools | array | Each entry is {type:"function", function:{name, description?, parameters?}} or {type:"file_search", file_search?:{vector_store_ids?}}. code_interpreter is not supported and returns tool_not_supported. A malformed entry returns invalid_tool_shape. Function names must match [A-Za-z_][A-Za-z0-9_-]{0,63}. |
| tool_resources | object | null | Resources the tools draw on. For RAG: tool_resources.file_search.vector_store_ids. This is the shape the OpenAI SDKs send and the run loop honours it on both the assistant and the thread. |
| temperature | number | null | Range [0, 2]. Out of range returns invalid_temperature. |
| top_p | number | null | Range [0, 1]. Out of range returns invalid_top_p. |
| response_format | object | null | Same shapes as chat: {type:"json_object"} or {type:"json_schema", json_schema}. Defaults to auto. |
| metadata | object | Up to 16 keys, key <=64 chars, value <=512 chars. Over the limit returns invalid_metadata. |
Assistants, threads and runs are an OpenAI compatibility shim, so their list endpoints return the OpenAI shape {object:"list", data, first_id, last_id, has_more} and paginate with after/before taking object ids. This differs from the rest of the Ringside API, which returns {data, has_more, next_cursor}. Both cap limit at 100 (default 20). Code against whichever envelope the endpoint you are calling actually returns.
curl https://api.fightclub.pro/v1/assistants \
-H "Authorization: Bearer ko_REPLACE_WITH_64_HEX" \
-H "Content-Type: application/json" \
-d '{
"model": "fc:openai/gpt-4o-mini",
"name": "billing-support",
"instructions": "You answer billing questions. Cite the docs you retrieve.",
"tools": [
{"type": "file_search"},
{"type": "function", "function": {
"name": "lookup_invoice",
"description": "Fetch an invoice by id",
"parameters": {"type": "object", "properties": {"invoice_id": {"type": "string"}}, "required": ["invoice_id"]}
}}
],
"tool_resources": {"file_search": {"vector_store_ids": ["vs_billing_kb"]}},
"temperature": 0.2
}'DELETE /v1/assistants/{id} flips the row to archived. It is idempotent (replaying a delete still returns deleted:true) and it does not break runs that already reference the assistant. Archived assistants drop out of list results, 404 on GET and can no longer back a new run. You cannot reuse an archived assistant name slot by accident, because only active assistants hold the unique-name lock.
Threads and spend attribution
A thread is the conversation. Create it empty or seed it with initial messages, then append more with POST /v1/threads/{id}/messages. Messages carry a role of user or assistant, and content is either a plain string or the OpenAI array form [{type:"text", text:{value}}]. Both are accepted and normalised on the way in.
Every thread is backed by a Ringside conversation, and that conversation must belong to a customer. How the customer is chosen depends on what you send and how you authenticated:
| You send | Auth | Customer used |
|---|---|---|
metadata.customer_id = cus_... | Bearer (dev key) | That customer, after an ownership check. An unknown id returns 400 customer_not_found. |
metadata.customer_external_id | Bearer (dev key) | The active customer with that external id. An unknown id returns 400 customer_not_found. |
| nothing | Bearer (dev key) | An implicit per-dev default customer (external id <your-user-id>_assistants_default), lazy-created on first thread. |
| anything | Client token (Authorization: Client) | Pinned to the token's customer_id claim. metadata.customer_id must equal it or you get 403 client_token_customer_mismatch; metadata.customer_external_id is rejected for client tokens with the same code. |
This matters because run cost lands on the resolved customer, not on a header passed per run. Decide attribution at thread-create time. If you are reselling, set metadata.customer_external_id to your own end-customer id so margin and usage reports line up per tenant. Thread metadata follows the same caps as everywhere else: 16 keys, 64-char keys, 512-char values.
curl https://api.fightclub.pro/v1/threads \
-H "Authorization: Bearer ko_REPLACE_WITH_64_HEX" \
-H "Content-Type: application/json" \
-d '{
"metadata": {"customer_id": "cus_42"},
"messages": [
{"role": "user", "content": "Why was I charged twice in March?"}
]
}'A thread holds at most 5,000 messages, enforced by a database CHECK. Appends past the cap return thread_full, and a run that would push a thread over the cap fails with 409 conversation_full. Long-running agents should start fresh threads rather than growing one forever. There is no truncation knob that raises this ceiling.
Runs and the lifecycle
Creating a run kicks an in-process executor. The run reads the thread, calls the model and writes the reply back as a new assistant message on the thread. If the model asks to call a function the run does not finish. It parks in requires_action and waits for you. One state machine covers every path.
| Status | Meaning | Terminal? |
|---|---|---|
queued | Created, lock acquired, executor not yet past the first model turn. | no |
in_progress | The executor is calling the model or dispatching a server-side tool. | no |
requires_action | The model emitted function tool calls. The run is suspended and the thread lock is released. Read required_action and submit outputs. | no |
cancelling | You called cancel while the executor was mid-flight. It will observe the flag and stop on its next step. | no |
completed | The model returned a final answer with no outstanding tool calls. The reply is on the thread. | yes |
cancelled | Cancelled cleanly. A queued run cancels immediately; a requires_action run cancels immediately; an in-flight run passes through cancelling first. | yes |
failed | The run hit a terminal error. last_error.code is one of invalid_model, wallet_empty, provider_error, response_schema_validation_failed, max_tool_iterations_exceeded or internal_error. | yes |
expired | The run outlived its 10-minute window without reaching a terminal state. last_error.code is run_expired. | yes |
+-------------+
create run -----------> | queued |
+------+------+
|
v
+-------------+ model returns content
+-------> | in_progress | -----------------------------> completed
| +------+------+
| | model emits function tool_calls
submit_tool_ | v
outputs | +------------------+
(loop) +------- | requires_action |
+------------------+
any non-terminal state may also end in:
cancel -> cancelling -> cancelled (in-flight)
cancel -> cancelled (queued / requires_action)
error -> failed
>10 min -> expiredA run takes a lock on its thread for the duration. While a run is queued, in_progress or cancelling, a second POST /v1/threads/{id}/runs returns 409 thread_locked and an attempt to append a message returns thread_locked too. The lock is released the moment the run parks in requires_action, so you can submit tool outputs, but you cannot start an unrelated run on the same thread in that window either. Serialize per thread; fan out across threads.
The function-calling loop
This is the core interaction. Your code and the run trade control until the model has everything it needs.
- 01POST a run. It goes
queuedthenin_progressand calls the model. - 02The model decides it needs a tool and emits function
tool_calls. The run writes those calls to the thread, transitions torequires_action, releases the thread lock and returns. - 03You poll
GET /v1/threads/{tid}/runs/{rid}(or read the streamed event) and seestatus:"requires_action". The work to do is inrequired_action.submit_tool_outputs.tool_calls, each with anid, a functionnameand JSONarguments. - 04You run each tool in your own code and collect a string
outputpertool_call_id. - 05POST
/submit_tool_outputswith{tool_outputs:[{tool_call_id, output}]}. The run re-acquires the lock, appends your outputs as tool-role messages and goes back toqueued. - 06The executor re-enters the loop with the new context. The model either emits more tool calls (back to step 2) or returns a final answer and the run goes
completed.
A single run will not bounce through requires_action forever. The executor counts tool-call rounds (the server-side file_search rounds plus your function rounds both count) and stops at 10. A run that asks for an eleventh round fails with last_error.code:"max_tool_iterations_exceeded". Design tools so the model converges well inside that budget, and split genuinely deep workflows across more than one run.
function tool calls are yours to answer; file_search calls are not. When the model calls file_search, the run executes the retrieval against your vector stores server-side, appends the results as tool messages and continues the loop in place, without ever surfacing requires_action for that call. You only see requires_action for function tools. A run that mixes both will dispatch the file_search calls itself and park only on the remaining function calls.
# 1. Create the run
RUN=$(curl -s https://api.fightclub.pro/v1/threads/thread_abc/runs \
-H "Authorization: Bearer ko_REPLACE_WITH_64_HEX" \
-H "Content-Type: application/json" \
-d '{"assistant_id":"asst_billing"}')
# RUN.status == "queued"
# 2. Poll until it leaves a non-terminal state
curl -s https://api.fightclub.pro/v1/threads/thread_abc/runs/run_xyz \
-H "Authorization: Bearer ko_REPLACE_WITH_64_HEX"
# -> {"status":"requires_action",
# "required_action":{"type":"submit_tool_outputs",
# "submit_tool_outputs":{"tool_calls":[
# {"id":"call_9","type":"function",
# "function":{"name":"lookup_invoice","arguments":"{\"invoice_id\":\"inv_312\"}"}}
# ]}}}
# 3. Run lookup_invoice yourself, then submit the output
curl -s https://api.fightclub.pro/v1/threads/thread_abc/runs/run_xyz/submit_tool_outputs \
-H "Authorization: Bearer ko_REPLACE_WITH_64_HEX" \
-H "Content-Type: application/json" \
-d '{"tool_outputs":[{"tool_call_id":"call_9","output":"{\"total\":42.00,\"status\":\"paid\"}"}]}'
# -> run goes back to "queued"; poll again until "completed"Both create-run and submit-tool-outputs also accept "stream": true, returning a text/event-stream of thread.run.* snapshots instead of a single object, so you can drive the loop without polling. The terminal events are thread.run.completed, thread.run.requires_action, thread.run.failed, thread.run.cancelled and thread.run.expired.
file_search for RAG inside a run
Attach a vector store to an assistant or a thread and the model can retrieve from it mid-run. Declare the stores under tool_resources.file_search.vector_store_ids, which is what the OpenAI SDKs send, on the assistant, on the thread or both. The run unions every declared store id and isolates retrieval to your own tenant; a cross-tenant store id simply matches nothing.
When file_search runs, the model gets chunk citations back as a tool message and continues. If the store was created with graphrag_enabled: true, the retrieval also returns a facts[] array of {subject, predicate, object} relationships alongside the chunks, so the model can reason over a knowledge graph as well as raw text. See RAG and vector stores for ingestion, embedding models, sealing and the graph build.
Errors and edge cases
| HTTP | type | code | When |
|---|---|---|---|
| 409 | conflict_error | assistant_name_in_use | Creating or renaming to a name an active assistant already holds. |
| 400 | invalid_request_error | invalid_model | The model ref does not resolve (e.g. a bare name, or an unknown @region suffix). |
| 400 | invalid_request_error | tool_not_supported | A tools entry uses an unsupported type such as code_interpreter. |
| 400 | invalid_request_error | invalid_tool_shape | A tools entry is malformed (bad function name, non-object entry, bad vector_store_ids). |
| 409 | conflict_error | thread_locked | A run is already active on the thread, or the lock could not be re-acquired when submitting tool outputs. Retry after a short backoff. |
| 409 | conflict_error | conversation_full | The run would push the thread past 5,000 messages. |
| 409 | conflict_error | run_not_in_requires_action | You called submit_tool_outputs on a run that is not parked waiting for outputs. |
| 409 | conflict_error | run_already_completed | You called cancel on a run already in a terminal state. |
| 400 | invalid_request_error | invalid_tool_call_id | A submitted tool_call_id is not one of the ids in required_action. |
| n/a | wallet_error | wallet_empty | The executor pre-checks the dev API pool before each model turn. An empty pool stops the run, which ends failed with last_error.code:"wallet_empty" rather than returning a 402. Customer budget and wallet limits are enforced on the chat-completions path, not re-checked inside the run loop. |
| n/a | - | max_tool_iterations_exceeded | The run cycled through more than 10 tool-call rounds without a final answer. It ends failed with this last_error.code. This is a run-object state, not an HTTP response. |
| 404 | not_found_error | - | Unknown or cross-tenant assistant, thread or run. Ringside returns 404, never 403, so ids are not enumerable. |
Two timing rules bite in production. First, a run expires 10 minutes after creation if it has not reached a terminal state; a background sweep flips stragglers to expired with last_error.code:"run_expired". If you park in requires_action and your tool work is slow, submit before the window closes or the run is gone. Second, because only one run is active per thread at a time, concurrent agents that share a thread will trip thread_locked. Treat that as backpressure: retry the same thread with a short delay, or route the work to a separate thread.
Both POST /v1/assistants and POST /v1/threads/{id}/runs accept the lowercase idempotency-key header. A replay within the 24h window, scoped to (developer, customer, key), returns the cached response rather than creating a second assistant or a second run. Use it on the create paths where a network retry would otherwise double up.
Limits and defaults
- ·Thread message cap: 5,000 (hard, DB-enforced).
- ·Run expiry: 10 minutes from creation to terminal state.
- ·Tool-loop iterations per run: 10 (file_search rounds plus function rounds combined).
- ·Active runs per thread: 1.
- ·Assistant / thread metadata: 16 keys, key <=64 chars, value <=512 chars.
- ·List pagination:
limitdefault 20, max 100; cursor viaafter/beforetaking object ids. - ·Supported tool types:
functionandfile_search.code_interpreteris not supported. - ·temperature in [0, 2]; top_p in [0, 1].
- ·Model refs must carry a prefix (
fc:/match:/slot:/dyn:); region suffix@<region>attaches tofc:refs only.
Related
Create stores, ingest files, embedding models, sealing and the graphrag facts[] output that file_search returns inside a run.
How thread metadata.customer_id / customer_external_id attributes run cost, plus markup and margin reporting.
End-to-end walkthrough: build an agent, wire its tools and drive the function-calling loop in production.
POST / GET / PATCH / DELETE /v1/assistants with every field and error.
Thread, message and tool_resources endpoints under /v1/threads.
Create, retrieve, list, cancel, submit_tool_outputs and run steps under /v1/threads/{id}/runs.