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.

You need a running local environment. 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.

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.

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
  }
}
{
  "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 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.

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:

query ReadAction {
  actions {
    action(id: "e2c75583-b217-4c0f-92b8-65cc6ace8e90") {
      version
      status
      pending
    }
  }
}
{ "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.

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
      }
    }
  }
}
{
  "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.

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 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 {}.

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 is an action of kind agent. This definition has five parts:

PartWhat it does here
toolMakes the agent callable, so you can start it with an input instead of a trigger.
agentThe prompt, and an outputSchema that makes the agent's output a typed verdict instead of free text. See Turn outcome.
tools and skillsThe 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.
policiesSELECT on accounts only. Every operation the agent's tools perform must be allowed by these too.
configThe strands runtime and the fixture model with its script. See 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.

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. The input is checked against the agent's tool.inputSchema before anything is queued.

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
    }
  }
}
{
  "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.

query ReadReview {
  actions {
    execution(id: "19f24ad6-5e61-4941-b14c-460b889570f6") {
      status
      actionVersion
      invocation {
        number
        output
        error {
          code
          message
        }
        uses {
          role
          action {
            definition {
              name
            }
          }
        }
      }
    }
  }
}
{
  "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.

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.

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
            }
          }
        }
      }
    }
  }
}
{
  "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: 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:

query ReadCustomerAccount {
  account(id: "1f07b601-1cb0-4e18-ae42-4b4971667036") {
    metadata
    version
  }
}
{ "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 records every model request and response and every tool call's input and output, in order.

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:

[
  {
    "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.

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:

{
  "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

SymptomCheck
createAccount reports a unique constraint violationThe account code is taken from an earlier run. Choose a fresh code and accountId.
invokeAction reports action is not callableThe 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 nameThe 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 RUNNINGRead it again after a few seconds. A real model takes longer than the fixture.
The execution fails with fixture_exhaustedThe 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_unfinishedThe 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_deniedRead the error's message and hint: they name the layer that refused, and the operation and resource it must allow.
startAction returns a finished executionRepeating a start with the same executionId and input returns the execution it started. Choose a fresh executionId for a new run.

Read Agents for how agents fit with tools, skills, and sessions, and the Agents reference 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.

Previous
Building Transactional Actions