# Your First Agent

Build an agent with a tool and a skill, run it, read every call and model exchange it recorded, and watch a policy stop it from writing.

In this tutorial you build an agent that reviews a newly opened account. It loads a skill that says how to review an account, reads the account with a tool, tries to flag it with a second tool, and reports a typed verdict. Its policies let it read accounts but not change them, so the flag is refused, and you see exactly where and why.

The agent uses the `fixture` model: a script of model responses that Twisp plays back in order. Every run gives the same result, and nothing calls a real model. An optional last step switches the agent to a Claude model.

> **Note:**
>
> You need a running [local environment](https://www.twisp.com/docs/infrastructure/local-environment.md). Open GraphiQL in the local console and run each step as its own request. If you have not used actions before, start with [Running Actions Locally](https://www.twisp.com/docs/tutorials/actions.md).

The UUIDs below are examples. If you have run this tutorial before, choose fresh UUIDs for every ID and execution ID, and a fresh account code.

## 1. Create the account

The agent reviews one account. This one has no `segment` in its metadata, which the review will catch.

```graphql
mutation CreateCustomerAccount {
  createAccount(
    input: {
      accountId: "1f07b601-1cb0-4e18-ae42-4b4971667036"
      name: "Ada Lovelace checking"
      code: "FIRST-AGENT-REVIEW"
      normalBalanceType: CREDIT
      metadata: { opened_via: "mobile" }
    }
  ) {
    accountId
    name
    code
    status
    metadata
  }
}
```

```json
{
  "accountId": "1f07b601-1cb0-4e18-ae42-4b4971667036",
  "name": "Ada Lovelace checking",
  "code": "FIRST-AGENT-REVIEW",
  "status": "ACTIVE",
  "metadata": { "opened_via": "mobile" }
}
```

## 2. Create a read-only tool

A [tool](https://www.twisp.com/docs/reference/actions/tools.md) is an action of kind `tool`: a Starlark program with input and output schemas. This one reads an account. The `name` is how other actions refer to it; `tool.name` is the name the agent's model sees. Its policies allow only `SELECT` on accounts, so it can read and nothing else.

```graphql
mutation CreateGetAccountTool {
  actions {
    createAction(
      id: "e2c75583-b217-4c0f-92b8-65cc6ace8e90"
      input: {
        name: "first-agent-get-account"
        description: "Read one account"
        kind: "tool"
        tool: {
          name: "get_account"
          description: "Read an account's name, code, status, and metadata."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" } }
            required: ["account_id"]
            additionalProperties: false
          }
          outputSchema: { type: "object" }
        }
        policies: [{ effect: ALLOW, actions: [SELECT], resources: ["financial.Account.*"] }]
        config: {
          runtime: "starlark"
          src: """
          def run(event):
              data = graphql(
                  "query Account($id: UUID!) { account(id: $id) { accountId name code status metadata } }",
                  {"id": event["data"]["account_id"]},
                  operation_name="Account",
              )
              if data["account"] == None:
                  fail("account not found", hint="Check account_id.")
              return data["account"]
          """
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

The response reports `version: 1`, `status: UNPUBLISHED`, and `pending: ACTIVE`. Poll until the tool is published:

```graphql
query ReadAction {
  actions {
    action(id: "e2c75583-b217-4c0f-92b8-65cc6ace8e90") {
      version
      status
      pending
    }
  }
}
```

```json
{ "version": 2, "status": "ACTIVE", "pending": null }
```

Publication advances `version` to 2. Use this query, with the ID changed, to wait for each action you create in this tutorial.

## 3. Call the tool yourself

Before an agent uses a tool, call it directly with `invokeAction`. The call runs synchronously, and `actionVersion` names the exact version to run: the published version from the last step.

```graphql
mutation CallGetAccount {
  actions {
    invokeAction(
      id: "e2c75583-b217-4c0f-92b8-65cc6ace8e90"
      input: {
        executionId: "ee714d72-5ca0-4855-bc9d-e65b4abf80dc"
        actionVersion: 2
        input: { account_id: "1f07b601-1cb0-4e18-ae42-4b4971667036" }
      }
    ) {
      executionId
      status
      invocation {
        output
      }
    }
  }
}
```

```json
{
  "executionId": "ee714d72-5ca0-4855-bc9d-e65b4abf80dc",
  "status": "SUCCEEDED",
  "invocation": {
    "output": {
      "accountId": "1f07b601-1cb0-4e18-ae42-4b4971667036",
      "code": "FIRST-AGENT-REVIEW",
      "metadata": { "opened_via": "mobile" },
      "name": "Ada Lovelace checking",
      "status": "ACTIVE"
    }
  }
}
```

## 4. Create a writing tool and a skill

The second tool flags an account by writing to its metadata. Its own policies allow it to change accounts: `UPDATE`, and `INSERT`, which an account update also needs. When its caller allows the same, it writes.

```graphql
mutation CreateFlagAccountTool {
  actions {
    createAction(
      id: "07e73646-97cc-4ec3-b321-8532ff33a174"
      input: {
        name: "first-agent-flag-account"
        description: "Flag one account for review"
        kind: "tool"
        tool: {
          name: "flag_account"
          description: "Mark an account for review by the operations team, with a reason."
          inputSchema: {
            type: "object"
            properties: {
              account_id: { type: "string", format: "uuid" }
              reason: { type: "string", minLength: 1 }
            }
            required: ["account_id", "reason"]
            additionalProperties: false
          }
          outputSchema: { type: "object" }
        }
        policies: [
          { effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.Account.*"] }
        ]
        config: {
          runtime: "starlark"
          src: """
          def run(event):
              data = graphql(
                  "mutation Flag($id: UUID!, $metadata: JSON!) { updateAccount(id: $id, input: { metadata: $metadata }) { accountId metadata } }",
                  {"id": event["data"]["account_id"], "metadata": {"review": "flagged", "reason": event["data"]["reason"]}},
                  operation_name="Flag",
              )
              return data["updateAccount"]
          """
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

A [skill](https://www.twisp.com/docs/reference/actions/skills.md) is reusable guidance. The agent sees its name and description, and loads its instructions and examples only when it needs them. A skill's `config` is always `{}`.

```graphql
mutation CreateReviewSkill {
  actions {
    createAction(
      id: "5cf755f0-726f-4bb9-af1c-b61b1d089c1d"
      input: {
        name: "first-agent-review-account"
        description: "How to review a new account"
        kind: "skill"
        config: {}
        skill: {
          instructions: """
          Read the account with get_account before judging it.
          An account is ok when its status is ACTIVE and its metadata names a segment.
          Otherwise flag it with flag_account, giving the field that is wrong as the reason.
          """
          examples: [
            {
              situation: "The account is LOCKED."
              resolution: "Flag it with the reason 'status is LOCKED'."
            }
          ]
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

Each reports `pending: ACTIVE`. Wait for both to publish before the next step, which refers to them by name.

## 5. Create the agent

An [agent](https://www.twisp.com/docs/reference/actions/agents.md) is an action of kind `agent`. This definition has five parts:

| Part                 | What it does here                                                                                                                                                                                                                            |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tool`               | Makes the agent [callable](https://www.twisp.com/docs/reference/actions/agents.md#callable-agents), so you can start it with an input instead of a trigger.                                                                                                               |
| `agent`              | The `prompt`, and an `outputSchema` that makes the agent's output a typed verdict instead of free text. See [Turn outcome](https://www.twisp.com/docs/reference/actions/agents.md#turn-outcome).                                                                          |
| `tools` and `skills` | The tools and skills the agent may use, by name. Each tool reference fixes `account_id` to the agent's own input, so the model cannot point either tool at another account. See [Tool references](https://www.twisp.com/docs/reference/actions/tools.md#tool-references). |
| `policies`           | `SELECT` on accounts only. Every operation the agent's tools perform must be allowed by these too.                                                                                                                                           |
| `config`             | The `strands` runtime and the `fixture` model with its script. See [Fixture model](https://www.twisp.com/docs/reference/actions/agents.md#fixture-model).                                                                                                                 |

The script has two entries. The first answers the agent's loop (`purpose == 'turn'`) with four responses in order: load the skill, call `get_account`, call `flag_account`, then reply with text, which ends the loop. The second answers the request for the turn's outcome (`purpose == 'outcome'`) with the reply and the typed output.

```graphql
mutation CreateReviewAgent {
  actions {
    createAction(
      id: "d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d"
      input: {
        name: "first-agent-reviewer"
        description: "Review a new account"
        kind: "agent"
        tool: {
          name: "review_account"
          description: "Review one new account and say whether it is ready."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" } }
            required: ["account_id"]
            additionalProperties: false
          }
        }
        agent: {
          prompt: "You review newly opened accounts. Load the review skill, follow it, and report a verdict."
          outputSchema: {
            type: "object"
            properties: {
              verdict: { type: "string", enum: ["ok", "flagged", "needs_human"] }
              summary: { type: "string" }
            }
            required: ["verdict", "summary"]
            additionalProperties: false
          }
        }
        tools: [
          { name: "first-agent-get-account", input: { account_id: "tool.input.account_id" } }
          { name: "first-agent-flag-account", input: { account_id: "tool.input.account_id" } }
        ]
        skills: [{ name: "first-agent-review-account" }]
        policies: [{ effect: ALLOW, actions: [SELECT], resources: ["financial.Account.*"] }]
        config: {
          runtime: "strands"
          model: {
            provider: "fixture"
            script: [
              {
                when: "purpose == 'turn'"
                responses: [
                  [
                    {
                      toolUse: {
                        name: "skills"
                        input: { skill_name: "first-agent-review-account" }
                      }
                    }
                  ]
                  [{ toolUse: { name: "get_account", input: {} } }]
                  [
                    {
                      toolUse: {
                        name: "flag_account"
                        input: { reason: "metadata has no segment" }
                      }
                    }
                  ]
                  [{ text: "The account has no segment, and I am not allowed to flag it." }]
                ]
              }
              {
                when: "purpose == 'outcome'"
                responses: [
                  {
                    outcome: {
                      reply: "The account has no segment. Flagging it was denied, so a person should review it."
                      output: {
                        verdict: "needs_human"
                        summary: "metadata has no segment; flag_account was denied"
                      }
                    }
                  }
                ]
              }
            ]
          }
        }
      }
    ) {
      actionId
      version
      status
      pending
    }
  }
}
```

Wait for `status: ACTIVE` and `pending: null`, at `version: 2`.

## 6. Start the agent

`startAction` queues the agent's whole run and returns at once, so a long run does not hold your request open. See [Starting a tool](https://www.twisp.com/docs/reference/actions/tools.md#starting-a-tool). The input is checked against the agent's `tool.inputSchema` before anything is queued.

```graphql
mutation StartReview {
  actions {
    startAction(
      id: "d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d"
      input: {
        executionId: "19f24ad6-5e61-4941-b14c-460b889570f6"
        input: { account_id: "1f07b601-1cb0-4e18-ae42-4b4971667036" }
      }
    ) {
      executionId
      status
      actionVersion
    }
  }
}
```

```json
{
  "executionId": "19f24ad6-5e61-4941-b14c-460b889570f6",
  "status": "QUEUED",
  "actionVersion": 2
}
```

Without `actionVersion`, the start runs the agent's current version and records it.

## 7. Read the result

Read the execution until its status is `SUCCEEDED`. A fixture run takes a few seconds.

```graphql
query ReadReview {
  actions {
    execution(id: "19f24ad6-5e61-4941-b14c-460b889570f6") {
      status
      actionVersion
      invocation {
        number
        output
        error {
          code
          message
        }
        uses {
          role
          action {
            definition {
              name
            }
          }
        }
      }
    }
  }
}
```

```json
{
  "status": "SUCCEEDED",
  "actionVersion": 2,
  "invocation": {
    "number": 1,
    "output": {
      "summary": "metadata has no segment; flag_account was denied",
      "verdict": "needs_human"
    },
    "error": null,
    "uses": [
      { "role": "SKILL", "action": { "definition": { "name": "first-agent-review-account" } } },
      { "role": "TOOL", "action": { "definition": { "name": "first-agent-get-account" } } }
    ]
  }
}
```

The output is the typed verdict, validated against `outputSchema`. `uses` lists the guidance and tool answers that reached the agent: the skill load and the account read. The refused `flag_account` call is not a use, because [failed calls are never uses](https://www.twisp.com/docs/reference/actions/agents.md#agent-uses).

## 8. Read the calls

Every skill load and tool call the agent made is an execution of its own, a child of the agent's execution, in the order it was made.

```graphql
query ReviewCalls {
  actions {
    execution(id: "19f24ad6-5e61-4941-b14c-460b889570f6") {
      children(first: 20) {
        nodes {
          executionId
          callSequence
          status
          action {
            definition {
              name
            }
          }
          event {
            data
          }
          invocation {
            output
            error {
              code
              message
              hint
            }
          }
        }
      }
    }
  }
}
```

```json
{
  "nodes": [
    {
      "executionId": "37916085-8838-5f15-9859-5545d8d700c6",
      "callSequence": 1,
      "status": "SUCCEEDED",
      "action": { "definition": { "name": "first-agent-review-account" } },
      "event": { "data": {} },
      "invocation": {
        "output": {
          "examples": [
            {
              "resolution": "Flag it with the reason 'status is LOCKED'.",
              "situation": "The account is LOCKED."
            }
          ],
          "instructions": "Read the account with get_account before judging it.\n..."
        },
        "error": null
      }
    },
    {
      "executionId": "d92fdb60-6d4c-534f-a5d7-101f9379bba0",
      "callSequence": 2,
      "status": "SUCCEEDED",
      "action": { "definition": { "name": "first-agent-get-account" } },
      "event": { "data": { "account_id": "1f07b601-1cb0-4e18-ae42-4b4971667036" } },
      "invocation": {
        "output": {
          "accountId": "1f07b601-1cb0-4e18-ae42-4b4971667036",
          "code": "FIRST-AGENT-REVIEW",
          "metadata": { "opened_via": "mobile" },
          "name": "Ada Lovelace checking",
          "status": "ACTIVE"
        },
        "error": null
      }
    },
    {
      "executionId": "f503dd48-5d88-5fa5-8df1-907962d79269",
      "callSequence": 3,
      "status": "FAILED",
      "action": { "definition": { "name": "first-agent-flag-account" } },
      "event": {
        "data": {
          "account_id": "1f07b601-1cb0-4e18-ae42-4b4971667036",
          "reason": "metadata has no segment"
        }
      },
      "invocation": {
        "output": null,
        "error": {
          "code": "action_access_denied",
          "message": "graphql: updateAccount: db:Update financial.Account.indexes.account_id: ... access denied by agent \"first-agent-reviewer\" (action d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d version 2): no policy allows it",
          "hint": "agent \"first-agent-reviewer\" (action d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d version 2) must allow db:Update on financial.Account.indexes.account_id"
        }
      }
    }
  ]
}
```

The script called `get_account` with empty input, yet the call ran with `account_id`: the agent's tool reference fixed it, and each call's execution records the input it actually ran with. The third call failed, and the next step looks at why. The child execution IDs are derived from the agent's execution, so yours differ if you chose a different execution ID.

## 9. See the policy stop the write

`flag_account`'s own policies allow it to update accounts, and you, the caller, have full access. The write was still refused, because every operation a tool performs for an agent must be allowed by every layer of the [policy chain](https://www.twisp.com/docs/reference/actions/actions.md#policy-chain): your client, then the agent's `policies`, then the tool's. The error names the layer that refused it: `access denied by agent "first-agent-reviewer"`, whose policies allow only `SELECT`.

The refused call left no writes. Read the account:

```graphql
query ReadCustomerAccount {
  account(id: "1f07b601-1cb0-4e18-ae42-4b4971667036") {
    metadata
    version
  }
}
```

```json
{ "metadata": { "opened_via": "mobile" }, "version": 1 }
```

The account is still at version 1. The failed call was returned to the model as an error it could act on, the turn went on, and the agent reported `needs_human`. An agent's `policies` bound everything it can reach through any tool, so giving an agent a tool never gives it more access than its own policies allow.

## 10. Read the transcript

The [transcript](https://www.twisp.com/docs/reference/actions/agents.md#transcripts) records every model request and response and every tool call's input and output, in order.

```graphql
query ReviewTranscript {
  actions {
    execution(id: "19f24ad6-5e61-4941-b14c-460b889570f6") {
      invocation {
        transcriptLastSequence
        transcript(first: 100) {
          nodes {
            sequence
            kind
            callExecutionId
            data
          }
          pageInfo {
            hasNextPage
          }
        }
      }
    }
  }
}
```

`transcriptLastSequence` is `28`, and the page holds all 28 events. The first `model.input` event holds Twisp's fixed instructions for every agent, your prompt, and the turn's first message, which carries the input you started the agent with. Each tool call then appears as a `model.response` that asks for it, and a `tool.input` and `tool.output` pair. Excerpts:

```json
[
  {
    "sequence": 1,
    "kind": "model.input",
    "callExecutionId": null,
    "data": {
      "messages": [
        {
          "role": "user",
          "content": [
            {
              "text": "<turn now=\"2026-10-11T17:58:05.014313Z\">\n<input>{\"account_id\":\"1f07b601-1cb0-4e18-ae42-4b4971667036\"}</input>\n</turn>"
            },
            "..."
          ]
        },
        "..."
      ],
      "system": ["..."],
      "...": "..."
    }
  },
  { "sequence": 2, "kind": "model.request", "...": "..." },
  {
    "sequence": 3,
    "kind": "model.response",
    "callExecutionId": null,
    "data": {
      "content": [
        {
          "toolUse": {
            "input": { "skill_name": "first-agent-review-account" },
            "name": "skills",
            "toolUseId": "tooluse_19f24ad6_1_1"
          }
        }
      ],
      "purpose": "turn",
      "stop_reason": "tool_use",
      "...": "..."
    }
  },
  { "sequence": 4, "kind": "usage", "...": "..." },
  {
    "sequence": 5,
    "kind": "tool.input",
    "callExecutionId": "37916085-8838-5f15-9859-5545d8d700c6",
    "data": {
      "input": { "skill_name": "first-agent-review-account" },
      "kind": "skill",
      "name": "first-agent-review-account",
      "...": "..."
    }
  },
  {
    "sequence": 6,
    "kind": "tool.output",
    "callExecutionId": "37916085-8838-5f15-9859-5545d8d700c6",
    "data": { "output": { "examples": ["..."], "instructions": "..." }, "...": "..." }
  },
  "...",
  {
    "sequence": 18,
    "kind": "tool.output",
    "callExecutionId": "f503dd48-5d88-5fa5-8df1-907962d79269",
    "data": {
      "error": {
        "code": "action_access_denied",
        "hint": "agent \"first-agent-reviewer\" (action d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d version 2) must allow db:Update on financial.Account.indexes.account_id",
        "...": "..."
      },
      "name": "flag_account",
      "...": "..."
    }
  },
  "...",
  {
    "sequence": 25,
    "kind": "model.response",
    "callExecutionId": null,
    "data": {
      "content": [
        {
          "toolUse": {
            "input": {
              "output": {
                "summary": "metadata has no segment; flag_account was denied",
                "verdict": "needs_human"
              },
              "reply": "The account has no segment. Flagging it was denied, so a person should review it."
            },
            "name": "TurnOutcome",
            "toolUseId": "tooluse_19f24ad6_1_4"
          }
        }
      ],
      "outcome": true,
      "purpose": "outcome",
      "...": "..."
    }
  },
  { "sequence": 26, "kind": "usage", "...": "..." },
  { "sequence": 27, "kind": "outcome.check", "callExecutionId": null, "data": { "valid": true } },
  "..."
]
```

Each `tool.input` and `tool.output` names its call in `callExecutionId`, the same `executionId` you saw in step 8, so you can go from what the model asked for to the execution that ran, and back. Event 18 is the error the model was shown for the refused write. Event 25 is the outcome, marked `outcome: true`, and event 27 is its check against `outputSchema`.

## 11. Optional: run it with Claude

Switch the agent from the script to a real model. `updateAction` replaces the whole `config`, so the update repeats `runtime`. This step calls the Anthropic API, so it needs a local environment configured with an Anthropic key, and it is billed like any API call.

```graphql
mutation UseClaude {
  actions {
    updateAction(
      id: "d9725d2a-f5c4-4bdb-a084-e47f8f6fa64d"
      input: {
        config: { runtime: "strands", model: { provider: "anthropic", id: "claude-haiku-5-5" } }
      }
    ) {
      version
      status
      pending
    }
  }
}
```

The response reports `version: 3` with `pending: ACTIVE`. Wait for `pending: null` at `version: 4`, then run step 6 again with a new `executionId`, such as `bd648967-724a-44a2-a35c-96d33345fcf1`, and read it with the queries from steps 7 and 8. The model now decides what to do from the prompt and the skill. A run gave:

```json
{
  "status": "SUCCEEDED",
  "actionVersion": 4,
  "invocation": {
    "output": {
      "summary": "Account 1f07b601-1cb0-4e18-ae42-4b4971667036 (Ada Lovelace checking) does not pass review: status is ACTIVE, but metadata has no segment (only opened_via: mobile). The flag_account call failed with action_access_denied, so the account has not been flagged in the system.",
      "verdict": "needs_human"
    },
    "...": "..."
  }
}
```

A real model's wording and choices vary from run to run, but the output always satisfies `outputSchema`, and the same policies bound what it can do: its `flag_account` call was refused just as the script's was.

## Troubleshooting

| Symptom                                               | Check                                                                                                                                                                         |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createAccount` reports a unique constraint violation | The account code is taken from an earlier run. Choose a fresh `code` and `accountId`.                                                                                         |
| `invokeAction` reports `action is not callable`       | The tool has not published yet. Wait for `status: ACTIVE` and `pending: null`, then call the `version` the read reports.                                                      |
| Creating the agent fails on a tool or skill name      | The tools and skill must exist and not be deleted. Names are the actions' `name`, such as `first-agent-get-account`, not their `tool.name`.                                   |
| The execution stays `QUEUED` or `RUNNING`             | Read it again after a few seconds. A real model takes longer than the fixture.                                                                                                |
| The execution fails with `fixture_exhausted`          | The model asked for more responses than the script has: an entry ran out. Each `when` entry keeps its own position, so count the responses in each.                           |
| The execution fails with `agent_unfinished`           | The outcome did not satisfy `outputSchema`. Check `verdict` is one of the enum values and `summary` is present. The `outcome.check` events in the transcript list the errors. |
| A tool call fails with `action_access_denied`         | Read the error's message and hint: they name the layer that refused, and the operation and resource it must allow.                                                            |
| `startAction` returns a finished execution            | Repeating a start with the same `executionId` and input returns the execution it started. Choose a fresh `executionId` for a new run.                                         |

Read [Agents](https://www.twisp.com/docs/actions/agents.md) for how agents fit with tools, skills, and sessions, and the [Agents reference](https://www.twisp.com/docs/reference/actions/agents.md) for every configuration field, model, and transcript event kind. To run an agent over a stream of events with state that carries from turn to turn, continue with [Sessions](https://www.twisp.com/docs/tutorials/actions/sessions.md).
