Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// concept · 10 min read

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 thread
An assistant is configuration; a thread is state; a run is the act of combining them.

Assistants

Create an assistant once and reuse it across many threads. The config is the contract for every run that names it.

POST /v1/assistants body. model is the only required field.
FieldTypeNotes
modelstring (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.
namestring | nullUnique among your active assistants. Reusing the name of an active assistant returns 409 assistant_name_in_use. Archived assistants do not block name reuse.
descriptionstring | nullFree text, your own use.
instructionsstring | nullThe system prompt applied to every run, unless a run overrides it.
toolsarrayEach 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_resourcesobject | nullResources 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.
temperaturenumber | nullRange [0, 2]. Out of range returns invalid_temperature.
top_pnumber | nullRange [0, 1]. Out of range returns invalid_top_p.
response_formatobject | nullSame shapes as chat: {type:"json_object"} or {type:"json_schema", json_schema}. Defaults to auto.
metadataobjectUp to 16 keys, key <=64 chars, value <=512 chars. Over the limit returns invalid_metadata.
Lists use the OpenAI envelope, not the Ringside one

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 is a soft archive

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:

Thread customer resolution. Once set, every run on the thread bills that customer's wallet.
You sendAuthCustomer 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_idBearer (dev key)The active customer with that external id. An unknown id returns 400 customer_not_found.
nothingBearer (dev key)An implicit per-dev default customer (external id <your-user-id>_assistants_default), lazy-created on first thread.
anythingClient 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?"}
    ]
  }'
The 5,000-message cap is hard

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.

Run statuses. created_at, started_at, expires_at, completed_at, cancelled_at and failed_at are Unix-seconds timestamps on the run object.
StatusMeaningTerminal?
queuedCreated, lock acquired, executor not yet past the first model turn.no
in_progressThe executor is calling the model or dispatching a server-side tool.no
requires_actionThe model emitted function tool calls. The run is suspended and the thread lock is released. Read required_action and submit outputs.no
cancellingYou called cancel while the executor was mid-flight. It will observe the flag and stop on its next step.no
completedThe model returned a final answer with no outstanding tool calls. The reply is on the thread.yes
cancelledCancelled cleanly. A queued run cancels immediately; a requires_action run cancels immediately; an in-flight run passes through cancelling first.yes
failedThe 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
expiredThe 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 -> expired
The run state machine. The requires_action <-> in_progress cycle is the function-calling loop.
One active run per thread

A 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.

  1. 01POST a run. It goes queued then in_progress and calls the model.
  2. 02The model decides it needs a tool and emits function tool_calls. The run writes those calls to the thread, transitions to requires_action, releases the thread lock and returns.
  3. 03You poll GET /v1/threads/{tid}/runs/{rid} (or read the streamed event) and see status:"requires_action". The work to do is in required_action.submit_tool_outputs.tool_calls, each with an id, a function name and JSON arguments.
  4. 04You run each tool in your own code and collect a string output per tool_call_id.
  5. 05POST /submit_tool_outputs with {tool_outputs:[{tool_call_id, output}]}. The run re-acquires the lock, appends your outputs as tool-role messages and goes back to queued.
  6. 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.
The tool loop is capped at 10 iterations

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.

file_search is dispatched for you

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

Error bodies follow {error:{type, code, message, param?}}. Branch on code, never the message string. Every response also carries the request id in the X-Request-Id header (not a body field); quote it when you file a support ticket.
HTTPtypecodeWhen
409conflict_errorassistant_name_in_useCreating or renaming to a name an active assistant already holds.
400invalid_request_errorinvalid_modelThe model ref does not resolve (e.g. a bare name, or an unknown @region suffix).
400invalid_request_errortool_not_supportedA tools entry uses an unsupported type such as code_interpreter.
400invalid_request_errorinvalid_tool_shapeA tools entry is malformed (bad function name, non-object entry, bad vector_store_ids).
409conflict_errorthread_lockedA run is already active on the thread, or the lock could not be re-acquired when submitting tool outputs. Retry after a short backoff.
409conflict_errorconversation_fullThe run would push the thread past 5,000 messages.
409conflict_errorrun_not_in_requires_actionYou called submit_tool_outputs on a run that is not parked waiting for outputs.
409conflict_errorrun_already_completedYou called cancel on a run already in a terminal state.
400invalid_request_errorinvalid_tool_call_idA submitted tool_call_id is not one of the ids in required_action.
n/awallet_errorwallet_emptyThe 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_exceededThe 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.
404not_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.

Idempotency on writes

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: limit default 20, max 100; cursor via after / before taking object ids.
  • ·Supported tool types: function and file_search. code_interpreter is 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 to fc: refs only.