Start a project Run and read its result

Dispatch a project's configured agent, follow the Run to a terminal status, and read its output.

This guide dispatches the agent configured on an existing Project with the Developer API, follows the Run until it reaches a terminal status, and reads its output. The project-scoped route uses the Project's agent template, so the request supplies only a command key and optional input; it does not need a prompt, model, tools, or context identifiers.

Before you start

You need:

  • a Project whose agent template is configured;
  • an API key with the automation manage capability and the run.dispatch action grant. The reads in this guide require the automation read capability, which manage also covers. Listing projects requires the projects read capability.

See API keys to create a key, and API overview for capability levels, action grants, and the error contract.

Set the origin and key once for the examples:

export API_ORIGIN="https://api.factorai.ai"
export API_KEY="<your API key>"

Read the Project's teamId and id from the projects visible to the key:

PROJECT=$(curl -sS "$API_ORIGIN/v1/projects" \
  -H "Authorization: Bearer $API_KEY" |
  jq -c '.projects[] | select(.slug == "my-project")')

TEAM_ID=$(printf '%s' "$PROJECT" | jq -r '.teamId')
PROJECT_ID=$(printf '%s' "$PROJECT" | jq -r '.id')

Steps

1. Dispatch the Run

POST /v1/teams/{teamId}/projects/{projectId}/runs accepts a required commandKey and an optional JSON input. commandKey is the idempotency key: a retry that reuses it with the same body replays the original dispatch instead of starting another Run. input is passed to the Project's agent template. Generate the key once and keep it with the request body until the dispatch succeeds. Reuse both unchanged after a lost response; use a new key only for a separate intended Run.

# Generate this once, then save and reuse it with the same request body on retry.
COMMAND_KEY="project-run-$(date +%s)-$RANDOM"
RUN=$(curl -sS -X POST \
  "$API_ORIGIN/v1/teams/$TEAM_ID/projects/$PROJECT_ID/runs" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"commandKey\": \"$COMMAND_KEY\",
    \"input\": {
      \"objective\": \"Summarize this week's open issues.\"
    }
  }")

RUN_ID=$(printf '%s' "$RUN" | jq -r '.run.id')
ATTEMPT_ID=$(printf '%s' "$RUN" | jq -r '.attempt.id')

The response carries the run, its immutable revision, the first attempt, a created flag, and integrationRequired.

2. Interpret the response

StatusMeaning
201A new Run was created. created is true; read run.id.
200The command key replayed an earlier dispatch. created is false.
202The policy requires approval before the Run starts.

A 200 replay returns the same run, revision, and attempt as the original dispatch, so a retried request is safe to handle the same way as a 201.

A 202 response carries action, approval.requestId, and approval.status: "approval_required". The persisted request executes once an approver resolves it; there is no Run ID to poll yet. Use a fresh commandKey only when you intend a separate dispatch.

3. Connect required integrations

When the Project's agent needs an integration the Run cannot use yet, integrationRequired lists each entry as { integration, setupUrl, tools }. Connect each integration at its setupUrl; the Run waits and resumes on its own. An empty array means nothing is missing.

printf '%s' "$RUN" |
  jq '.integrationRequired[] | { integration, setupUrl, tools }'

The same array appears on GET /v1/runs/{runId} while an Attempt is waiting, so recheck it if the Run does not start.

4. Poll the Run

GET /v1/runs/{runId} returns the Run and its execution history. Repeat the read while run.status is pending, running, or waiting. completed, failed, and cancelled are terminal; on failed, read the Attempt's error for the cause.

while :; do
  SNAPSHOT=$(curl -sS "$API_ORIGIN/v1/runs/$RUN_ID" \
    -H "Authorization: Bearer $API_KEY")
  STATUS=$(printf '%s' "$SNAPSHOT" | jq -r '.run.status')
  case "$STATUS" in
    pending | running | waiting) sleep 5 ;;
    *) break ;;
  esac
done

printf '%s' "$SNAPSHOT" |
  jq '{status: .run.status, attempts: [.attempts[] | {id, status, error, waitingReason, resultArtifactIds}]}'

A waiting Attempt also reports waitingReason (approval, policy, dependency, or user).

5. Read the result

GET /v1/runs/{runId}/result returns the selected Attempt's status, error, waitingReason, durable assistant output, and final artifacts. By default it selects the latest Attempt on the newest revision; pass runAttemptId to read a specific Attempt.

curl -sS "$API_ORIGIN/v1/runs/$RUN_ID/result" \
  -H "Authorization: Bearer $API_KEY" |
  jq '{status, output, error, artifacts}'

output is null until the Attempt produces durable output, and artifacts carries the final artifacts with resolved download URLs. A 503 with run_output_unavailable means the stored output could not be read; retry the read.

Errors

The dispatch route documents these failures:

  • 400 - the dispatch body was rejected as an invalid Run request.
  • 401 - the request is missing a valid API key.
  • 403 - the key lacks the automation manage capability, the run.dispatch action grant, or access to the Project.
  • 404 - the Project does not exist for the key.
  • 409 - the Project is not dispatchable (no agent template, unknown template, or invalid configuration), or the Run is halted.
  • 422 - the Run request is invalid.

The reads in this guide return 401, 403, 404, and, for the result read, 503. See API overview for the full error contract.

Next steps