# ELTEX Agent State API

Persist agent identity, task progress, decisions, checkpoints, resume runs, private managed files, and external artifact references across sessions.

**Base URL:** `https://eltexlabs.com/v1`  
**Availability:** all paid Agent Plans; Free is not supported  
**Deployment status:** the database migration is applied. The public endpoints become available after this application revision is deployed to Firebase App Hosting.

Managed upload deployment requires a private R2 bucket and server credentials. See `docs/cloudflare-r2-agent-state.md` for the dashboard setup and environment variables.

## Authentication and scopes

Send an ELTEX API key or OAuth access token as a Bearer token:

```http
Authorization: Bearer sk-eltex-...
```

The credential needs the scope required by the endpoint. API keys and OAuth tokens use the same four Agent State scopes:

| Scope | Access |
|---|---|
| `agents:read` | List and read agents. |
| `agents:write` | Create and update agents. |
| `tasks:read` | Read tasks, state, events, checkpoints, included artifacts, and managed file content. |
| `tasks:write` | Create tasks; update state; create checkpoints and resume runs; upload, attach, or detach artifacts. |

A missing/invalid credential returns `401 invalid_api_key`. A missing scope returns `403 insufficient_scope`. A Free-plan credential returns `403 agent_runtime_paid_plan_required`.

## Write safety

Every `POST`, `PATCH`, and `DELETE` request requires a stable `Idempotency-Key` header. Reuse the same key and exact body only when retrying the same intended action. A completed retry returns the stored response. Reusing a key with a different body returns `409 idempotency_key_reused`; retrying while the first request is still running returns `409 idempotency_request_in_progress` with `Retry-After`.

```http
Idempotency-Key: pricing-task-progress-1
```

`PATCH /v1/agents/{agent_id}` and `POST /v1/tasks/{task_id}/state/operations` also require the current version. Prefer `If-Match`; `expected_version` in the JSON body is accepted as an alternative.

```http
If-Match: "1"
```

An omitted version returns `428 state_version_required`. A stale agent version returns `409 resource_version_conflict`; a stale task-state version returns `409 state_version_conflict`. Successful task-state updates return the new version in both the response body and `ETag`.

## Typical lifecycle

1. Create an agent.
2. Create a task for that active agent.
3. Record progress as ordered state operations using the latest `state.version`.
4. Create checkpoints at useful recovery points.
5. Upload work products as private managed files or attach external references as artifacts.
6. If execution stops, resume from a checkpoint to create a new run.

All resources are restricted to the authenticated owner. Knowing another user's resource ID does not grant access.

## Agents

### Create an agent

```http
POST /v1/agents
Authorization: Bearer sk-eltex-...
Idempotency-Key: create-pricing-researcher-v1
Content-Type: application/json

{
  "name": "pricing-researcher",
  "framework": "custom-node",
  "description": "Researches competitor pricing",
  "metadata": {"team":"growth"}
}
```

Returns `201` with an `agent` object. `name` is required. `framework` defaults to `custom`; `description` and `metadata` are optional.

### List, read, and update agents

```http
GET   /v1/agents?status=active&limit=25&cursor=OPAQUE_CURSOR
GET   /v1/agents/{agent_id}
PATCH /v1/agents/{agent_id}
```

Agent status may be `active`, `inactive`, or `archived`. Updateable fields are `name`, `description`, `framework`, `status`, and `metadata`. A patch needs `Idempotency-Key` and the current agent `version` through `If-Match` or `expected_version`.

## Tasks and state

### Create a task

```http
POST /v1/tasks
Authorization: Bearer sk-eltex-...
Idempotency-Key: create-pricing-task-v1
Content-Type: application/json

{
  "agent_id": "agt_...",
  "goal": "Research competitor pricing",
  "constraints": [
    "Do not submit a form without approval",
    "Keep source URLs"
  ]
}
```

The agent must exist, belong to the caller, and be active. The response is `201` and includes the initial state at `state.version: 1`.

### List and read tasks

```http
GET /v1/tasks?agent_id=agt_...&status=in_progress&limit=25&cursor=OPAQUE_CURSOR
GET /v1/tasks/{task_id}
GET /v1/tasks/{task_id}?include=artifacts
```

Task status may be `created`, `in_progress`, `blocked`, `paused`, `completed`, `failed`, `cancelled`, or `archived`. `include=artifacts` adds up to 100 active artifact references to the task response.

### Apply state operations

```http
POST /v1/tasks/tsk_.../state/operations
Authorization: Bearer sk-eltex-...
Idempotency-Key: pricing-progress-1
If-Match: "1"
Content-Type: application/json

{
  "run_id": "run_...",
  "operations": [
    {"op":"set_status","status":"in_progress"},
    {"op":"set_current_step","step":{"id":"collect","summary":"Collect pricing pages"}},
    {"op":"add_decision","decision":"Use public pricing pages","rationale":"They are directly verifiable"},
    {"op":"set_next_actions","items":["Collect remaining sources","Compare plans"]}
  ]
}
```

`run_id` is optional, but when supplied it must identify a run belonging to this task. A request accepts 1 to 100 operations and the resulting state is limited to 256 KiB.

Supported operation names:

- Goal and status: `set_goal`, `set_status`, `set_current_step`
- Subgoals: `add_subgoal`, `update_subgoal`
- Constraints: `add_constraint`, `resolve_constraint`
- Decisions: `add_decision`, `supersede_decision`
- Steps: `complete_step`, `fail_step`
- Blockers: `add_blocker`, `resolve_blocker`
- Planning: `set_next_actions`
- Observations: `record_observation`, `invalidate_observation`

The response is a `task_state` object with `version`, `event_sequence`, `checksum`, and `data`. Use its new `version` for the next update.

### Read state events

```http
GET /v1/tasks/{task_id}/events?after_sequence=0&limit=25
```

Events are returned in ascending sequence order. When `next_after_sequence` is not `null`, pass that value as the next `after_sequence`.

## Checkpoints and resume

### Create and read checkpoints

```http
POST /v1/tasks/tsk_.../checkpoints
Authorization: Bearer sk-eltex-...
Idempotency-Key: checkpoint-after-collection
Content-Type: application/json

{"label":"Collected competitor sources","run_id":"run_..."}
```

`label` and `run_id` are optional. The server verifies the current state checksum before storing the snapshot.

```http
GET /v1/tasks/{task_id}/checkpoints?limit=25&cursor=OPAQUE_CURSOR
GET /v1/tasks/{task_id}/checkpoints/{checkpoint_id}
```

Checkpoint lists omit the snapshot; reading one checkpoint includes its `state` after checksum verification.

### Resume a task

```http
POST /v1/tasks/tsk_.../resume
Authorization: Bearer sk-eltex-...
Idempotency-Key: resume-attempt-2
Content-Type: application/json

{"checkpoint_id":"chk_...","agent_id":"agt_..."}
```

Both fields are optional. The server uses the latest checkpoint and the task's current agent when omitted. The selected agent must be active. Resume returns `201`, creates a new running `run`, links it to the verified checkpoint, and marks the task `in_progress`. It does not replace the task's current state with the checkpoint snapshot or delete later history. A task with an active run returns `409 active_run_exists`; a task without a usable checkpoint returns `409 checkpoint_required`.

## Managed files and artifact references

### Upload a private managed file

Send the file as the raw request body. Do not use JSON or a multipart form. ELTEX streams the authenticated request into its private Cloudflare R2 bucket and stores only ownership, integrity, and object-key metadata in PostgreSQL.

```http
POST /v1/tasks/tsk_.../artifacts/upload
Authorization: Bearer sk-eltex-...
Idempotency-Key: upload-report-v1
Content-Type: application/pdf
X-File-Name: quarterly-report.pdf
X-Artifact-Label: Quarterly%20report
X-Artifact-Sensitivity: sensitive

<raw PDF bytes>
```

`X-File-Name` is required and must be URL-encoded when it contains non-ASCII characters. `X-Artifact-Label` is optional and follows the same encoding. Sensitivity defaults to `standard`. The default limits are 25 MiB per file and 250 MiB of active managed files per account.

The response is an artifact object with `managed: true`, `filename`, `size_bytes`, a SHA-256 checksum, and an authenticated `download_url`. The R2 bucket remains private; credentials, bucket object keys, and presigned URLs are never returned.

Download with the same owner and a credential carrying `tasks:read`:

```http
GET /v1/tasks/{task_id}/artifacts/{artifact_id}/content
Authorization: Bearer sk-eltex-...
```

The response uses `Content-Disposition: attachment`, `Cache-Control: private, no-store`, and `X-Content-Type-Options: nosniff`.

### Attach an external reference

External artifacts can still be attached without copying their binary payload into R2:

```http
POST /v1/tasks/tsk_.../artifacts
Authorization: Bearer sk-eltex-...
Idempotency-Key: attach-source-1
Content-Type: application/json

{
  "type":"url",
  "uri":"https://example.com/pricing",
  "label":"Example pricing page",
  "sensitivity":"public",
  "metadata":{"source":"competitor-site"}
}
```

At least one of `uri` or `external_id` is required. Optional fields are `run_id`, `label`, `content_type`, `size_bytes`, `checksum`, `sensitivity`, and `metadata`.

- Types: `file`, `url`, `commit`, `screenshot`, `browser_session`, `document`, `external`
- URI schemes: `https`, `gs`, `s3`
- Sensitivity: `public`, `standard` (default), `sensitive`, `restricted`

Local paths and browser credentials are not accepted as URIs. Detaching an external reference does not delete the external asset. Detaching a managed file marks the artifact detached and deletes its private R2 object:

```http
DELETE /v1/tasks/{task_id}/artifacts/{artifact_id}
Authorization: Bearer sk-eltex-...
Idempotency-Key: detach-source-1
```

## Pagination

Agent, task, and checkpoint lists accept `limit` from 1 to 100 (default 25) and return `next_cursor`. Treat the cursor as opaque and send it back unchanged. Task events use numeric `after_sequence` and return `next_after_sequence` instead.

```json
{"object":"list","data":[],"next_cursor":null}
```

Invalid opaque cursors return `400 invalid_cursor`.

## Endpoint and scope summary

| Method | Endpoint | Scope |
|---|---|---|
| `POST` | `/v1/agents` | `agents:write` |
| `GET` | `/v1/agents` | `agents:read` |
| `GET` | `/v1/agents/{agent_id}` | `agents:read` |
| `PATCH` | `/v1/agents/{agent_id}` | `agents:write` |
| `POST` | `/v1/tasks` | `tasks:write` |
| `GET` | `/v1/tasks` | `tasks:read` |
| `GET` | `/v1/tasks/{task_id}` | `tasks:read` |
| `POST` | `/v1/tasks/{task_id}/state/operations` | `tasks:write` |
| `GET` | `/v1/tasks/{task_id}/events` | `tasks:read` |
| `POST` | `/v1/tasks/{task_id}/checkpoints` | `tasks:write` |
| `GET` | `/v1/tasks/{task_id}/checkpoints` | `tasks:read` |
| `GET` | `/v1/tasks/{task_id}/checkpoints/{checkpoint_id}` | `tasks:read` |
| `POST` | `/v1/tasks/{task_id}/resume` | `tasks:write` |
| `POST` | `/v1/tasks/{task_id}/artifacts/upload` | `tasks:write` |
| `GET`, `HEAD` | `/v1/tasks/{task_id}/artifacts/{artifact_id}/content` | `tasks:read` |
| `POST` | `/v1/tasks/{task_id}/artifacts` | `tasks:write` |
| `DELETE` | `/v1/tasks/{task_id}/artifacts/{artifact_id}` | `tasks:write` |

## Errors

Errors use the standard ELTEX envelope. Branch on `error.code`, not message text.

```json
{
  "error": {
    "message": "Task state version conflict; current version is 3",
    "type": "state_version_conflict",
    "code": "state_version_conflict"
  }
}
```

| HTTP | Important codes | Client action |
|---:|---|---|
| 400 | `invalid_request_error`, `invalid_idempotency_key`, `invalid_cursor`, `invalid_state_operation`, `state_item_not_found`, `state_item_exists`, `state_collection_limit`, `state_size_limit`, `invalid_artifact_type`, `invalid_artifact_sensitivity`, `invalid_artifact_uri`, `invalid_artifact_reference`, `invalid_artifact_file` | Fix the request; do not retry unchanged. |
| 401 | `invalid_api_key` | Replace or refresh the credential. |
| 403 | `insufficient_scope`, `agent_runtime_paid_plan_required` | Add the scope or use a paid-plan credential. |
| 404 | `agent_not_found`, `task_not_found`, `run_not_found`, `checkpoint_not_found`, `artifact_not_found` | Reconcile IDs and owner context. |
| 409 | `idempotency_key_reused`, `idempotency_request_in_progress`, `resource_version_conflict`, `state_version_conflict`, `active_run_exists`, `checkpoint_required` | Re-read current state, then retry only when safe. |
| 428 | `state_version_required` | Send the current version in `If-Match` or `expected_version`. |
| 413 | `artifact_file_too_large`, `artifact_storage_quota_exceeded` | Upload a smaller file or remove managed files before retrying. |
| 410 | `artifact_content_unavailable` | Stop; the managed object is no longer available in R2. |
| 500 | `state_checksum_mismatch`, `checkpoint_checksum_mismatch` | Stop and report the integrity failure. |
| 503 | `agent_state_migration_required`, `artifact_storage_unavailable` | Apply the migration or configure private R2 storage before retrying. |

All `/v1` responses include `X-Request-ID`; retain it when reporting a failure.
