# ELTEX Agent RunGuard API

RunGuard is the deterministic runtime control layer for agent runs. It reserves cost before work, limits model/tool/browser actions and runtime, detects repeated actions/results/errors, records trust-labelled usage, and can pause or terminate a run before another action is authorized.

**Base URL:** `https://eltexlabs.com/v1`  
**Availability:** all paid Agent Plans; Free is not supported  
**Deployment status:** implemented and verified against the local preview database. Migration `0002_runguard.sql` is applied in production; this application revision and `RUNGUARD_TOKEN_SECRET` still need to be deployed through Firebase App Hosting before the endpoints are externally available.

## Current enforcement boundary

RunGuard uses a two-phase protocol:

1. The agent calls `preflight` with an exact action descriptor and maximum estimated cost.
2. An `ALLOW` or `WARN` response contains a short-lived execution token bound to the owner, run, execution, and action digest.
3. The agent performs the action.
4. The agent calls `complete` with the token, outcome, final cost, result/error fingerprint input, and optional progress evidence.

ELTEX can hard-enforce this protocol when the action is mediated by an ELTEX service. Direct calls from a third-party agent to arbitrary external tools are cooperative and are recorded as `client_reported`; RunGuard cannot prevent a client from bypassing the SDK and calling that tool directly.

Native approval decisions are not part of this revision. `sensitive` and `critical` risk labels are recorded but do not automatically create approvals yet. Approval-bound actions will be added in the next RunGuard milestone.

## Authentication and scopes

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

| Scope | Access |
|---|---|
| `runs:read` | Read guard policies, runs, and run events. |
| `runs:write` | Create/update policies; start and control runs; preflight/complete actions; report cooperative events. |
| `runtime.usage:read` | Read normalized RunGuard usage. |

API keys and OAuth access tokens use the same scopes. These scopes require a paid plan.

## Idempotency and concurrency

Every `POST` and `PATCH` request requires `Idempotency-Key`. Reuse the same key with the same body only when retrying the same intended action. A completed retry returns the original response; a changed body returns `409 idempotency_key_reused`.

Policy updates additionally require the current policy resource `version` through `If-Match` or `expected_version`.

Cost reservation and counter decisions are serialized under a run row lock. Concurrent preflights therefore see the latest reserved cost and cannot collectively authorize more than the configured run/task budget.

## Policy schema

A run stores an immutable normalized snapshot. Updating a reusable policy creates a new policy version and does not rewrite existing runs.

```json
{
  "max_cost_per_run": 0.50,
  "max_cost_per_task": 1.00,
  "warn_threshold_percent": 80,
  "max_model_calls": 100,
  "max_tool_calls": 200,
  "max_browser_actions": 200,
  "max_runtime_seconds": 3600,
  "max_consecutive_failures": 5,
  "max_retries_per_tool": 3,
  "repeated_action": {"threshold":4,"window":5},
  "repeated_result": {"threshold":3,"window":5},
  "repeated_error": {"threshold":3,"window":5},
  "loop_decision": "PAUSE",
  "execution_ttl_seconds": 900
}
```

`max_cost_per_run` and `max_cost_per_task` default to `null` (no monetary limit) unless explicitly set. Other fields use the protective defaults shown above. An explicit `null` disables a call/runtime/failure limit. `loop_decision` may be `WARN`, `PAUSE`, or `TERMINATE`. Execution TTL accepts 30–3600 seconds.

### Create and manage reusable policies

```http
POST /v1/guard-policies
Idempotency-Key: create-research-policy-v1
Content-Type: application/json

{
  "name":"Research guard",
  "policy":{"max_cost_per_run":0.50,"max_tool_calls":50}
}
```

```text
GET   /v1/guard-policies?limit=25&cursor=OPAQUE_CURSOR
GET   /v1/guard-policies/{policy_id}
PATCH /v1/guard-policies/{policy_id}
```

`PATCH` may change `name`, `status` (`active` or `archived`), and/or `policy`. A changed policy creates the next immutable policy version.

## Start and read a run

Use either `guard_policy_id` or inline `guard`, not both:

```http
POST /v1/runs
Authorization: Bearer sk-eltex-...
Idempotency-Key: start-pricing-run-v1
Content-Type: application/json

{
  "task_id":"tsk_...",
  "agent_id":"agt_...",
  "checkpoint_id":"chk_...",
  "guard_policy_id":"gpl_..."
}
```

`agent_id` defaults to the task agent. `checkpoint_id` is optional. Inline `guard` defaults to the normalized protective policy. A task may have only one active run (`created`, `running`, `awaiting_approval`, or `paused`).

```http
GET /v1/runs?status=paused&task_id=tsk_...&limit=25
GET /v1/runs/{run_id}
```

The list endpoint returns runs ordered by most recently updated and supports opaque `cursor` pagination plus optional `status` and `task_id` filters. List items include the task goal and agent name when available. A run response contains status, immutable policy snapshot, counters, committed/reserved cost, progress/event sequence, source checkpoint, and terminal reason.

## Preflight an action

```http
POST /v1/runs/run_.../preflight
Authorization: Bearer sk-eltex-...
Idempotency-Key: run-1-model-call-7
Content-Type: application/json

{
  "action": {
    "action_type":"model_call",
    "capability":"chat.completions",
    "risk":"read_only",
    "arguments":{"model":"eltex/smart","purpose":"compare pricing"}
  },
  "estimated_cost_usd":0.04,
  "cost_source":"client_estimated"
}
```

`action_type` is `model_call`, `tool_call`, or `browser_action`. Tool calls require `tool_name`. Risk is `read_only`, `interactive`, `sensitive`, or `critical`. Public clients may report `client_estimated` or `byok_estimated` cost only; server adapters will later supply authoritative `eltex_managed`/`provider_reported` values.

Volatile transport keys such as `timestamp`, `request_id`, `trace_id`, `nonce`, and `idempotency_key` are removed before hashing. Effect-bearing arguments remain in the digest.

Allowed response:

```json
{
  "object":"guard.decision",
  "decision":"ALLOW",
  "run_id":"run_...",
  "execution_id":"exe_...",
  "execution_token":"...",
  "expires_at":"...",
  "reason_codes":[]
}
```

`WARN` also contains a token and permits execution. `PAUSE` or `TERMINATE` has no execution token. Hard-limit decisions revoke outstanding uncompleted grants and release their reserved cost.

Counters increment when an action is authorized, even if its reservation later expires. This intentionally limits repeated authorization attempts. Expired monetary reservations are released during the next preflight; a background reconciliation job may be added later.

## Complete an execution

Completion must arrive before the execution token expires. Retrying an already completed request with the original idempotency key still returns the stored response even after token expiry.

```http
POST /v1/runs/run_.../executions/exe_.../complete
Authorization: Bearer sk-eltex-...
Idempotency-Key: run-1-model-call-7-complete
Content-Type: application/json

{
  "execution_token":"...",
  "outcome":"succeeded",
  "final_cost_usd":0.037,
  "cost_source":"client_estimated",
  "result":{"summary":"Compared five plans"},
  "progress":{
    "kind":"progress",
    "evidence":{"state_version":4}
  }
}
```

`outcome` is `succeeded`, `failed`, or `cancelled`. Public API completion is always stored with `client_reported` trust. Result and error bodies are normalized and hashed for loop detection; raw values are not stored in the execution row.

Progress claims become `server_verified_state` when the referenced state version is newer than the state observed at preflight and exists on the task. An active artifact created after preflight can provide `server_verified_artifact`. Unverified claims remain `claimed` and do not override hard limits.

## Loop and failure decisions

RunGuard evaluates:

- repeated action digests within the configured window;
- repeated result or error hashes;
- consecutive failed completions;
- consecutive failed attempts of the same tool;
- call, runtime, run-cost, and task-cost limits;
- whether verified state/artifact progress occurred since a repeated action.

Decisions include stable `reason_codes`, for example `max_model_calls`, `max_cost_per_run`, `repeated_action_detected`, `repeated_result_detected`, `max_consecutive_failures`, or `repeated_action_with_progress`.

## Run events and cooperative reporting

```text
GET  /v1/runs/{run_id}/events?after_sequence=0&limit=25
POST /v1/runs/{run_id}/events
```

Server decisions use `server_observed`. Publicly reported events use `client_reported` and may be:

- `model.call.started`, `model.call.completed`, `model.call.failed`
- `tool.call.started`, `tool.call.completed`, `tool.call.failed`
- `browser.action.started`, `browser.action.completed`, `browser.action.failed`
- `progress`, `no_progress`, `subgoal_completed`, `blocker_detected`

Events are immutable and ordered by server sequence. Use `next_after_sequence` for the next page. Common credential fields such as authorization, cookie, password, token, secret, API key, and credential are recursively redacted before cooperative payloads are stored; clients must still avoid sending secrets in arbitrary text fields.

## Pause, resume, terminate, and usage

```text
POST /v1/runs/{run_id}/pause
POST /v1/runs/{run_id}/resume
POST /v1/runs/{run_id}/terminate
GET  /v1/runs/{run_id}/usage
```

Control requests accept optional `{"reason":"..."}`. Pause and terminate revoke outstanding grants and release reservations. Terminate is final. Resume works only from `paused` and refuses when the configured wall-clock runtime limit has already elapsed.

Usage groups records by action category, cost source, and trust, while also returning total committed and currently reserved cost.

## Endpoint summary

| Method | Endpoint | Scope |
|---|---|---|
| `POST` | `/v1/guard-policies` | `runs:write` |
| `GET` | `/v1/guard-policies` | `runs:read` |
| `GET` | `/v1/guard-policies/{policy_id}` | `runs:read` |
| `PATCH` | `/v1/guard-policies/{policy_id}` | `runs:write` |
| `POST` | `/v1/runs` | `runs:write` |
| `GET` | `/v1/runs/{run_id}` | `runs:read` |
| `GET` | `/v1/runs/{run_id}/events` | `runs:read` |
| `POST` | `/v1/runs/{run_id}/events` | `runs:write` |
| `POST` | `/v1/runs/{run_id}/preflight` | `runs:write` |
| `POST` | `/v1/runs/{run_id}/executions/{execution_id}/complete` | `runs:write` |
| `POST` | `/v1/runs/{run_id}/pause` | `runs:write` |
| `POST` | `/v1/runs/{run_id}/resume` | `runs:write` |
| `POST` | `/v1/runs/{run_id}/terminate` | `runs:write` |
| `GET` | `/v1/runs/{run_id}/usage` | `runtime.usage:read` |

## Important errors

| HTTP | Code | Meaning/action |
|---:|---|---|
| 400 | `invalid_guard_policy`, `invalid_guard_action`, `guard_action_too_large`, `invalid_run_event` | Fix the policy/action/event. |
| 401 | `invalid_api_key`, `invalid_execution_token`, `execution_token_expired` | Replace/refresh the credential or preflight again when safe. |
| 403 | `insufficient_scope`, `agent_runtime_paid_plan_required`, `invalid_cost_source_claim`, `invalid_trust_claim`, `execution_token_binding_mismatch` | Use the proper paid credential and do not claim server authority. |
| 404 | `guard_policy_not_found`, `run_not_found`, `guard_execution_not_found` | Reconcile owner-scoped IDs. |
| 409 | `active_run_exists`, `run_not_active`, `invalid_run_transition`, `guard_limit_reached`, `execution_not_active`, idempotency conflicts | Read current run state before deciding whether to retry. |
| 428 | `resource_version_required` | Send policy `If-Match` or `expected_version`. |
| 503 | `runguard_migration_required` | Apply migration `0002_runguard.sql` and deploy the matching application revision. |

All `/v1` responses include `X-Request-ID`; retain it with failure reports.
