---
name: anlyon-agent-loop
description: Handles what an Anlyon action call returns inside an AI agent's loop - pending approvals, connect_required, refused calls, unknown outcomes and receipts - so the agent waits, asks the user, or stops instead of retrying blindly. Use when writing or debugging code or prompts around actions.invoke, waitForDecision, wait_for_approval or check_approval MCP tools, anlyon_check_request, the @anlyonhq/ai-sdk tool results, idempotency keys, or an invocation whose status is pending_approval, failed, unknown, denied or expired.
license: MIT
metadata:
  author: Anlyon
  version: "1.0.0"
  docs: https://docs.anlyon.com/skills/anlyon-agent-loop
---

# Handling Anlyon results in an agent loop

Every action call ends in one of a few states. The agent's next move depends on the state, never on the HTTP status alone: a call Anlyon refused still answers `200` with the refusal in the body.

## What to do with each result

| `status` (and `grade`) | Meaning | The agent should |
| --- | --- | --- |
| `succeeded` (`confirmed`) | Done, and a read-back matched | Report it, with the invocation or effect id |
| `succeeded` (`acknowledged`) | The provider accepted it; no read-back | Report it as accepted, not as verified |
| `pending_approval` | Parked for a person. Nothing sent | Wait for the decision (below). **Never invoke again** |
| `connect_required` / `reconnect_required` (tool result) or `409 END_USER_NOT_CONNECTED` / `END_USER_RECONNECT_REQUIRED` | The end user has no working account. Nothing sent | Show `connectUrl`, tell the user, stop this step. Retry after they connect |
| `failed` with `grade: refused` | Anlyon stopped it at dispatch: no room in a limit, an unbounded amount, a request that changed after approval. Nothing sent | Do not retry. Tell the user why (`code`, `error`) |
| `failed` | The call did not happen, or the provider refused it (`4xx`) | Fix the cause, then retry deliberately (below) |
| `unknown` | It **may** have happened (timeout, dropped connection, provider `5xx`) | **Never retry.** Tell a person; they reconcile |
| `denied` / `expired` | A person or a policy said no (`decision.code` `HUMAN_DENIED` or `POLICY_DENIED`), or nobody decided in time. Nothing sent | Tell the user, with `decision.explanation`. Do not resubmit unless they ask |
| `running` | Being sent now | Read the invocation again shortly |

Refusal codes (`code` on AI SDK results, inside `error` on the invocation): `impact_limit_exceeded` (with `limits: [{ name, requested, available }]`), `impact_unbounded`, `policy_denied`, `approval_denied`, `approval_expired`.

## In code (TypeScript SDK)

```typescript
import { Client, EndUserConnectionRequiredError } from '@anlyonhq/sdk';
const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

async function run(name: string, input: Record<string, unknown>, operationId: string) {
  // Same business operation → same key. A resend returns the recorded invocation instead of running twice.
  let { data: inv } = await anlyon.actions.invoke(name, input, { idempotencyKey: `${name}:${operationId}` });

  if (inv!.pendingApproval) {
    const approval = await anlyon.approvals.waitForDecision(inv!.approvalId!, { timeoutMs: 10 * 60_000 });
    if (approval.status === 'pending') return { state: 'waiting', approvalId: approval.id };
    inv = (await anlyon.actions.invocation(inv!.id)).data; // approved: Anlyon sent the reviewed request
  }

  switch (inv!.status) {
    case 'succeeded': return { state: 'done', grade: inv!.grade, effectId: inv!.effectId };
    case 'unknown':   return { state: 'needs_reconcile', invocationId: inv!.id }; // never retry
    default:          return { state: inv!.status, error: inv!.error };          // failed, denied, expired, running
  }
}
```

Wrap the call in `try/catch` for `EndUserConnectionRequiredError` when acting for an end user (it carries `integration` and `connectUrl`). Other thrown errors are API errors (bad key, missing scope): see the `anlyon-troubleshooting` skill.

- `waitForDecision` long-polls (default 5 minutes) and returns the approval; `status` is `approved`, `denied`, `expired`, or still `pending` when the wait ran out. The approval stays open.
- After approval, the invocation can still be `running` for a moment; read it again.
- End-user approvals are decided in your app. `endUsers.approvals.decide(...)` returns the executed `invocation` directly.

## With the Vercel AI SDK

`anlyonTools({ client, actions })` returns results, not errors, for refusals and connect prompts. Each has `status`, `message` (a sentence the model can relay), `invocationId`, `approvalId`, `effectId`, `grade`, `code`, `limits`. The `message` already tells the model not to retry a refusal or an `unknown`. The older `createAnlyonTools(client)` returns a plain sentence for each call (an object only for a connect prompt), so prefer `anlyonTools` when your code branches on the result.

- Gated call → `pending_approval`. Show the approval (for example `<ApprovalPrompt>` from `@anlyonhq/react`). When the user says they decided, the model calls `anlyon_check_request` with the `invocationId`.
- `createAnlyonTools` also gives the model `waitForApproval` (up to 120 seconds a call), for a reviewer on the team.
- A model that calls the same action twice makes two invocations. For one business operation, write your own tool that passes your `idempotencyKey`.

## Over MCP (Claude Code, Cursor, Codex, any MCP agent)

1. `list_actions` to see actions, their input schemas and which need approval.
2. Call `action_<name>` (or `invoke_action`).
3. If the result names an `apr_…` id: call `wait_for_approval` with it (60 seconds by default, 120 at most). Still `pending`? Call it again, or tell the user who must approve and stop. Do not call the action again.
4. A `connect_required` result: pass `message` and `connectUrl` to the user.
5. `unknown`: say so plainly and stop.

Never decide an approval the same connection requested; the API refuses it, and it defeats the point. Use `request_approval` before a consequential step that is not an action, then `wait_for_approval`.

## Retrying on purpose

Anlyon never retries an invocation. The SDK retries its own transport (network error, `429`, `5xx`) with the same idempotency key, which is safe.
- `failed` and fixed: invoke again with `retryOf: '<inv_id>'` and a **new** idempotency key. Refused while the original is `unknown`.
- `unknown`: a person reconciles it before anything else (console, or `actions.reconcileEffect(effectId)`; recording the real outcome needs `actions:resolve` on a different key).
- `409 idempotency_key_reuse`: the key was used with a different body. `409 idempotency_request_in_progress`: call again with the same key to get the original result.

Field-by-field result shapes: [references/results.md](references/results.md).
