> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The Assistant's agentic loop: reason, act, observe

> A technical walkthrough of how the ModuleX Assistant runs a turn: the reason to act to observe loop, the shared chat store with kind=assistant, turns and runs, and how a turn terminates — with parameters, events, errors, and credit impact.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

This page is the technical reference for what happens inside a single Assistant turn. It covers the agentic reason to act to observe loop, the storage and execution model the Assistant shares with the [AI Composer](/concepts/ai-composer), the relationship between turns and runs, every way a turn can terminate, the limits that bound it, and the exact credit impact. If you are looking for the product tour first, start with the [Assistant overview](/assistant/overview).

The Assistant runs the `assistant` profile of the same agent core that powers the Composer. The two are separated only by `kind` and `profile`: the Composer edits a [workflow](/concepts/workflows-and-runs) graph, while the Assistant has no workflow tools at all and acts directly on your connected [integration tools](/assistant/using-tools) and [knowledge bases](/concepts/knowledge-rag).

## The reason, act, observe loop

A turn is an agentic loop. The Assistant alternates between calling the language model (reason) and calling exactly one tool (act), then feeds the tool result back into the model (observe), and repeats until the model produces a final answer with no further tool call.

<MediaEmbed id="MX-MEDIA-3230" type="image" caption={"A diagram of the Assistant's reason to act to observe loop for a single turn."} />

Each pass of the loop produces streamed events you can observe live over [SSE](/assistant/streaming):

<Steps>
  <Step title="Reason — the model decides the next step">
    The Assistant calls the language model with the conversation so far and the available tools. The model streams its text as `response_chunk` events. It either ends the turn with a final answer, or it decides to call a tool.
  </Step>

  <Step title="Act — one tool runs">
    If the model asks for a tool, the Assistant emits a `tool_call` event and runs that single action. Tool calls are serialized: `SerialToolCallsMiddleware` forces one tool call per model step, so two tools never fire in the same step. The Assistant can call a tool that discovers integrations, runs an integration action, searches knowledge, or asks you a [human-in-the-loop](/assistant/human-in-the-loop) question.
  </Step>

  <Step title="Observe — the result feeds back">
    The Assistant emits a `tool_result` event carrying the tool's output (or an error payload on failure) and feeds it back to the model. The loop returns to the reason step.
  </Step>

  <Step title="Repeat until the model is done">
    The loop continues until the model returns a turn with no tool call. The Assistant then emits a `done` event with the final response and usage totals, and the turn ends.
  </Step>
</Steps>

The same loop powers the Composer, but the Assistant's tool set is restricted to discovery (`get_available_integrations`, `get_integration_details`, `get_organization_credentials`), execution (`execute_integration_tool`), knowledge (`search_knowledge`), and the HITL tools (`ask_user_choice`, `ask_user_multi_choice`, `ask_user_yes_no`, `ask_user_free_text`, `request_credential`). Workflow inspect, build, edit, and run tools are deliberately excluded — a resumed Assistant run can never regain them because the profile is re-derived from the chat's `kind`.

## Storage and execution model

Assistant and Composer chats share the same conversation store, distinguished by a `kind` value (`assistant` vs `composer`), so they cannot read or operate on each other's chats. Every service call is scoped by `kind`. The Assistant intentionally reuses the Composer's agent factory, executor, HITL interrupt/resume machinery, OAuth auto-resume, and SSE streaming — staying in the shared store is required so that OAuth auto-resume keeps working.

<ResponseField name="chat" type="object">
  The shared chat backing both surfaces.

  <Expandable title="Key fields">
    <ResponseField name="id" type="string (UUID)">
      The chat identifier. This value **is** the conversation checkpoint `thread_id`, so `thread_id == chat_id` for the life of the conversation.
    </ResponseField>

    <ResponseField name="kind" type="string">
      `assistant` for Assistant chats, `composer` for Composer chats. The discriminator that scopes every service call.
    </ResponseField>

    <ResponseField name="title" type="string">
      Defaults to the first 50 characters of the opening message.
    </ResponseField>

    <ResponseField name="running_id" type="string (UUID) | null">
      The `run_id` of the in-flight or interrupted run, set synchronously at run start and cleared on completion, failure, or cancel. It is the race-free binding for run-to-chat ownership.
    </ResponseField>

    <ResponseField name="workflow_id" type="null">
      Always `null` for Assistant chats — they are never workflow-bound. Because of this, the executor's `workflow_change` and `workflow_sync` events never fire for the Assistant.
    </ResponseField>
  </Expandable>
</ResponseField>

What differs from the Composer at runtime: the Assistant has no subagents (the Composer uses an integration-resolver and a credential-resolver), runs slimmer middleware (it keeps `SerialToolCallsMiddleware` and `SummarizationMiddleware` plus provider prompt caching, and skips the todo-list, filesystem, and subagent middleware), and uses an Assistant system prompt with no workflow or focus language. The `save`, `revert`, and `focus` endpoints that the Composer exposes are omitted entirely.

## Turns and runs

These two words are not interchangeable; the distinction matters for billing, streaming, and identity.

<CardGroup cols={2}>
  <Card title="Turn" icon="message-square">
    One user message answered by one agent loop. A turn starts with `POST /assistant/chat` and ends when the loop reaches a final answer, pauses for input, is cancelled, or errors. **Billing charges exactly one run credit per turn.**
  </Card>

  <Card title="Run" icon="play">
    A single execution of the loop, identified by a `run_id`. A turn maps to one run — but when a turn pauses for [human-in-the-loop](/assistant/human-in-the-loop) input and you resume it, a **new** `run_id` is minted for the continuation. So one logical turn can span more than one `run_id`.
  </Card>
</CardGroup>

### The three run-id identities

`run_id` is used at several layers, and you must not assume one identity. The same caution applies to workflow runs — see [workflows and runs](/concepts/workflows-and-runs) for the full treatment.

<ResponseField name="per-execution run_id" type="string (UUID)">
  The identifier used for SSE streaming, status, and history keys. **A new `run_id` is minted on every resume**, so it is not stable across a multi-step conversation and is not a conversation-level key.
</ResponseField>

<ResponseField name="thread_id" type="string (UUID)">
  The conversation checkpoint thread. `thread_id == chat_id` and is stable across the whole conversation. Every run in a chat re-enters the same thread.
</ResponseField>

<ResponseField name="durable run_id" type="string">
  The durable run identifier persisted on the run row, a unique string value. It is the stable durable identifier the docs reference for a run.
</ResponseField>

### How a turn is admitted, charged, and tracked

When `POST /assistant/chat` is called, the Assistant runs a synchronous, reject-before-write sequence so a denied turn never writes any rows:

<Steps>
  <Step title="Concurrency guards">
    If the chat already has a pending HITL question, the request returns **409** (answer it first). If a run is already in flight on the chat, the request returns **409** (wait for it or cancel it). A chat holds only one open question and one running turn at a time.
  </Step>

  <Step title="Billing admission gate">
    The Assistant calls the run-admission gate with the turn key `{chat.id}:{run_id}`, usage type `run`, and rate class `sync_exec`. Assistant turns count under `sync_exec` unconditionally. On denial it raises a billing error (**402 / 403 / 429**) before any rows are written. See [credit impact](#credit-impact).
  </Step>

  <Step title="Persist and charge">
    On success the Assistant appends your message, sets `running_id` to the new `run_id`, commits, and charges exactly one run credit for the turn. Any error on this path releases the admission reservation so it does not leak.
  </Step>

  <Step title="Execute in the background">
    The loop is scheduled as a background task with `profile="assistant"` and `workflow_id=None`. The response returns immediately with a `stream_url` you open to watch the turn.
  </Step>
</Steps>

The `POST /assistant/chat` response gives you everything you need to start streaming:

```json Response theme={null}
{
  "status": "running",
  "chat_id": "f2b1c0de-1111-2222-3333-444455556666",
  "run_id": "a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd",
  "thread_id": "f2b1c0de-1111-2222-3333-444455556666",
  "stream_url": "/assistant/chat/f2b1c0de-1111-2222-3333-444455556666/listen/a1b2c3d4-aaaa-bbbb-cccc-dddddddddddd"
}
```

## Termination and limits

A turn's loop ends in exactly one of four ways. Three are terminal for the run; one (HITL pause) keeps the chat alive awaiting your response.

<AccordionGroup>
  <Accordion title="Natural completion — the model stops calling tools" icon="circle-check">
    The model returns a turn with no tool call. The Assistant emits a `done` event carrying the final `response`, the list of `tool_calls`, and a `usage` object (`input_tokens`, `output_tokens`, `total_tokens`, `llm_calls`). For the Assistant, `has_workflow_changes` is always `false` and `workflow_tool` is always `null` (the composer-shaped keys are present but empty). The run status becomes `completed` and `running_id` is cleared.
  </Accordion>

  <Accordion title="Human-in-the-loop pause — the model asks you a question" icon="hand">
    The model calls a HITL tool (`ask_user_*` or `request_credential`), which fires an interrupt. The Assistant writes an interrupt-audit row (`outcome="pending"`), publishes a `user_input_request` event, sets a server-side pending sentinel (TTL **7 days**), flips run status to `interrupted`, appends a history-only `interrupted` marker, and cold-exits the loop awaiting resume. The chat stays open — this is not a terminal run for the conversation. You answer with `POST /assistant/chat/{chat_id}/resume`, which mints a **new** `run_id` and re-enters the same thread. See [human-in-the-loop](/assistant/human-in-the-loop) and the [HITL resume reference](/realtime/hitl).
  </Accordion>

  <Accordion title="Cancellation — you stop the turn" icon="circle-stop">
    `POST /assistant/chat/{chat_id}/cancel` sets a server-side cancel flag, publishes a `cancelled` event, and sets run status to `cancelled`. If the run was paused on a HITL interrupt, cancel also clears the pending sentinel and flips the audit row to `cancelled` so the question is not re-presented. Then `running_id` is cleared. Calling cancel with no active run returns **400** (`No active execution to cancel`).
  </Accordion>

  <Accordion title="Error — the turn fails" icon="triangle-alert">
    On failure the Assistant emits an `error` event with a sanitized message, followed by a `done` event with `error: true`, and sets run status to `failed`. Language-model token usage accrued before the failure is still recorded (token billing is independent of success).
  </Accordion>
</AccordionGroup>

### Loop limits

The loop is bounded so it cannot run forever, and it emits guidance long before it hits the ceiling.

<ParamField path="recursion_limit" type="integer" default="100">
  The hard ceiling on agent loop iterations for a single run, enforced by the execution engine. A run that exceeds it stops with an error rather than looping indefinitely. There is no per-turn wall-clock timeout exposed in the run config; the recursion limit is the effective bound.
</ParamField>

<ParamField path="CONSECUTIVE_FAILURE_THRESHOLD" type="integer" default="3">
  After this many tool failures in a row, the Assistant publishes a `guidance` event (`{message, consecutive_failures}`) so the model — and you — know something is going wrong. The counter resets on the next successful tool call. This nudges the loop, it does not terminate it.
</ParamField>

<ParamField path="LLM_CALL_WARNING_THRESHOLD" type="integer" default="20">
  Every multiple of this many language-model calls in a single run, the Assistant publishes a wind-down `guidance` event (`{message, total_llm_calls}`) encouraging the model to converge. This is advisory, not a hard stop.
</ParamField>

<Note>
  A `guidance` event is informational. It signals that the loop is struggling (repeated failures) or running long (many LLM calls), but it does not end the turn. Only `done`, `cancelled`, `error`, and the HITL `interrupted` pause change the run's lifecycle.
</Note>

## Events emitted during a turn

The turn streams as Server-Sent Events. Each frame is a single `data: <json>` line with **no** `event:` line — discriminate on the JSON `type` field. The payload for most events is nested under a `data` key, for example `{"type":"response_chunk","data":{"chunk":"..."}}`. See [streaming responses](/assistant/streaming) for how to consume the stream in the app and the SDKs, and the [SSE run streaming reference](/realtime/sse-streaming) for the transport details.

| `type`               | Payload                                                              | Emitted when                                                                              |
| -------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `metadata`           | `{run_id, thread_id, workflow_id, workflow_type, timestamp}`         | At run start.                                                                             |
| `response_chunk`     | `{chunk}`                                                            | On each model text delta (the reason step).                                               |
| `tool_call`          | `{tool, input, tool_call_id, timestamp}`                             | When a tool is invoked (the act step).                                                    |
| `tool_result`        | `{tool, tool_call_id, output, timestamp}`                            | When a tool returns (the observe step); on failure `output` is `{error, success: false}`. |
| `guidance`           | `{message, consecutive_failures}` or `{message, total_llm_calls}`    | On the failure threshold or every LLM-call milestone.                                     |
| `user_input_request` | `{kind, request_id, message, ...}`                                   | When a HITL tool fires an interrupt.                                                      |
| `interrupted`        | `{request_id, kind}`                                                 | History-only terminal marker for the paused run.                                          |
| `run_resumed`        | `{new_run_id}`                                                       | On OAuth auto-resume, published on the old run's channel.                                 |
| `done`               | `{response, has_workflow_changes, tool_calls, workflow_tool, usage}` | On success — terminal.                                                                    |
| `error`              | `{message}`                                                          | On failure, followed by a `done` with `error: true`.                                      |
| `cancelled`          | `{reason}`                                                           | After `POST /assistant/chat/{chat_id}/cancel`.                                            |
| `heartbeat`          | `{type: "heartbeat"}`                                                | Keepalive every 15s of inter-event silence.                                               |

<Warning>
  For Assistant runs, the `metadata` frame reports `workflow_type` as `composer` and `workflow_id` as `null`. Use the request's `surface` metadata tag (`assistant`) as the per-surface discriminator, not `workflow_type`. The `workflow_change` and `workflow_sync` events do not fire for the Assistant.
</Warning>

## Credit impact

The Assistant meters usage like any other managed run. Two distinct charges apply per turn:

<CardGroup cols={2}>
  <Card title="One run credit per turn" icon="coins">
    The flat run charge is **1 credit** per turn, applied through the admission gate at the start of the turn. Resuming a paused turn re-enters through `/resume` (not `/chat`), so a resume does **not** add a second run charge for the same turn.
  </Card>

  <Card title="Token usage, billed separately" icon="calculator">
    Language-model token usage is recorded separately in the executor, based on input and output token counts, regardless of whether the turn succeeds, errors, or is cancelled. This is independent of the flat run credit.
  </Card>
</CardGroup>

If the loop calls integration tools or searches managed [knowledge](/platform/knowledge/managed), those carry their own credit costs documented in [credits and metering](/billing/credits). Bring-your-own-key model usage is not credited (it is recorded for analytics only).

### When a turn is denied

The Assistant is one of the surfaces where the billing admission gate is live. If your organization's allowance and wallet cannot cover the turn, the gate denies it **before any rows are written** and the request fails with a flat `DenialEnvelope` — note this has no `detail` wrapper, unlike standard FastAPI errors:

```json DenialEnvelope (402) theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": null,
  "current": null,
  "limit": null,
  "reason": "credit_plan_exhausted"
}
```

The `layer` field maps to the HTTP status: `credit` and `wallet` to **402**, `quota` to **403**, and `rate` to **429** (with `Retry-After` and `X-RateLimit-*` headers). For the full envelope taxonomy and how it differs from the `{detail}` shape, see [errors and status codes](/api-reference/errors); for the gating rules and per-plan limits, see [usage gating and limits](/billing/usage-gating) and [permissions and limits](/assistant/permissions-and-limits).

## A worked example: one turn with a tool call

This walks one full turn end to end: start the turn, stream the loop, watch a tool call and its result, and receive the final answer. Every request uses `Authorization: Bearer mx_live_…` plus `X-Organization-ID` — see [authentication](/api-reference/authentication). As built today the Assistant requires the **owner** or **admin** role in the organization; the retired `member` role is not a current role.

<Steps>
  <Step title="Start the turn">
    Send one text message. The `message` must be a JSON string (v1 is text-only — an array or object returns **400**). Omit `chat_id` to start a new chat, or pass it to continue one. If you omit `llm`, your organization's saved default is used.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.modulex.dev/assistant/chat \
        -H "Authorization: Bearer mx_live_your_api_key" \
        -H "X-Organization-ID: org_your_organization_id" \
        -H "Content-Type: application/json" \
        -d '{
          "message": "List my GitHub repositories",
          "llm": {
            "integration_name": "openai",
            "provider_id": "openai",
            "model_id": "gpt-4o"
          }
        }'
      ```

      ```python Python theme={null}
      from modulex import AsyncModulex

      client = AsyncModulex(
          api_key="mx_live_your_api_key",
          organization_id="org_your_organization_id",
      )

      resp = await client.assistant.chat(
          "List my GitHub repositories",
          llm={
              "integration_name": "openai",
              "provider_id": "openai",
              "model_id": "gpt-4o",
          },
      )
      print(resp.chat_id, resp.run_id)
      ```

      ```javascript JavaScript theme={null}
      import { Modulex } from "modulex-js";

      const client = new Modulex({
        apiKey: "mx_live_your_api_key",
        organizationId: "org_your_organization_id",
      });

      const resp = await client.assistant.chat({
        message: "List my GitHub repositories",
        llm: {
          integration_name: "openai",
          provider_id: "openai",
          model_id: "gpt-4o",
        },
      });
      console.log(resp.chat_id, resp.run_id);
      ```
    </CodeGroup>
  </Step>

  <Step title="Open the stream">
    Open the returned `stream_url` to receive the turn's events as they happen. Both SDKs expose `assistant.listen(chatId, runId)` as an async iterator that yields the data payload of each frame.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -N https://api.modulex.dev/assistant/chat/{CHAT_ID}/listen/{RUN_ID} \
        -H "Authorization: Bearer mx_live_your_api_key" \
        -H "X-Organization-ID: org_your_organization_id"
      ```

      ```python Python theme={null}
      async with client.assistant.listen(resp.chat_id, resp.run_id) as stream:
          async for event in stream:
              if event.event == "response_chunk":
                  print(event.data["chunk"], end="")
              elif event.event == "tool_call":
                  print(f"\n[tool] {event.data['tool']}")
              elif event.event == "done":
                  print("\n[final]", event.data["response"])
      ```

      ```javascript JavaScript theme={null}
      for await (const event of client.assistant.listen(resp.chat_id, resp.run_id)) {
        if (event.type === "response_chunk") {
          process.stdout.write(event.data.chunk);
        } else if (event.type === "tool_call") {
          console.log(`\n[tool] ${event.data.tool}`);
        } else if (event.type === "done") {
          console.log("\n[final]", event.data.response);
        }
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Read the loop in the frames">
    A clean single-tool turn looks like this on the wire (reason, act, observe, then a final answer):

    ```text Frame trace theme={null}
    data: {"type":"metadata","data":{"run_id":"a1b2c3d4-...","thread_id":"f2b1c0de-...","workflow_id":null,"workflow_type":"composer","timestamp":"2026-06-20T10:00:01+00:00"}}

    data: {"type":"response_chunk","data":{"chunk":"Let me check your GitHub."}}

    data: {"type":"tool_call","data":{"tool":"execute_integration_tool","input":{"integration":"github","action":"list_repos"},"tool_call_id":"run-xyz","timestamp":"2026-06-20T10:00:02+00:00"}}

    data: {"type":"tool_result","data":{"tool":"execute_integration_tool","tool_call_id":"run-xyz","output":{"success":true,"repos":["acme/api","acme/web"]},"timestamp":"2026-06-20T10:00:03+00:00"}}

    data: {"type":"response_chunk","data":{"chunk":"You have 2 repositories: acme/api and acme/web."}}

    data: {"type":"done","data":{"response":"You have 2 repositories: acme/api and acme/web.","has_workflow_changes":false,"tool_calls":[{"tool":"execute_integration_tool","input":{"integration":"github","action":"list_repos"},"output":{"success":true,"repos":["acme/api","acme/web"]}}],"workflow_tool":null,"usage":{"input_tokens":1200,"output_tokens":340,"total_tokens":1540,"llm_calls":3}}}
    ```
  </Step>

  <Step title="Handle a pause, if one occurs">
    If the model instead emits a `user_input_request` (for example, to connect a missing credential), the run pauses and the stream stays open on heartbeats. Answer with `assistant.resume(...)` — which mints a new `run_id` you then open a fresh stream on. The full request and response kinds are in [human-in-the-loop](/assistant/human-in-the-loop).
  </Step>
</Steps>

<Note>
  The `message` field must be a JSON string; an array or object returns **400**. Continuing a chat that belongs to another organization returns **404** (ownership is hidden as not-found, not 403). A second turn while one is pending or running returns **409**.
</Note>

## Where to go next

<CardGroup cols={2}>
  <Card title="Using tools" icon="wrench" href="/assistant/using-tools">
    How the Assistant discovers and calls integration tools in the act step, and how it requests credentials.
  </Card>

  <Card title="Streaming responses" icon="radio" href="/assistant/streaming">
    Consume the turn's events live over SSE in the app and the SDKs.
  </Card>

  <Card title="Human-in-the-loop" icon="hand" href="/assistant/human-in-the-loop">
    The pause-and-resume contract: question kinds, response kinds, and how resume mints a new run.
  </Card>

  <Card title="Permissions and limits" icon="shield" href="/assistant/permissions-and-limits">
    Who can use the Assistant and the billing and usage limits that bound a turn.
  </Card>

  <Card title="Workflows and runs" icon="play" href="/concepts/workflows-and-runs">
    The run-id identities and the difference between a turn and a run, in the wider ModuleX model.
  </Card>

  <Card title="The Assistant concept" icon="lightbulb" href="/concepts/assistant">
    Where the Assistant sits relative to the Composer and the workflow engine.
  </Card>
</CardGroup>
