> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Call your API from a tool

> Call an HTTP API from a tool with fetch, keep the key in the agent's environment, and handle timeouts and failures

After this guide, a tool reads a secret from the agent's environment, calls your API with `fetch`, and returns a result the model can use, or a clear error when it can't. The pattern fits any HTTP service; when the service is one of Lua's [integrations](/concepts/integrations), connect it instead and skip the key handling.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A tool registered in a skill on the agent ([Add a tool to a skill](/build/add-a-tool)).
* The API's base URL and a key for it.

<Steps>
  <Step title="Store the key for local runs">
    `lua env sandbox` writes the project's `.env` file. `lua test` loads it before it runs your code, and `lua chat -e sandbox` uploads its values with each sandbox version, so sandbox turns read them from the platform.

    ```bash theme={null}
    lua env sandbox -k ORDERS_API_URL -v https://api.example.com
    lua env sandbox -k ORDERS_API_KEY -v <key>
    ```

    The command rewrites `.env` from its key-value pairs, so don't keep hand-written comments in it. Add `.env` to `.gitignore`; the scaffold doesn't.
  </Step>

  <Step title="Call the API from execute">
    `env('KEY')` returns the value or `undefined`. Read what you need at the top, fail fast with a message the model can relay, and bound the wait with a timeout well under the tool's budget.

    ```ts src/skills/tools/GetOrderStatusTool.ts theme={null}
    import { LuaTool, env } from 'lua-cli';
    import { z } from 'zod';

    export default class GetOrderStatusTool implements LuaTool {
      name = 'get_order_status';
      description =
        'Look up the shipping status of one order by order number. ' +
        'Use when a customer asks where an order is or when it will arrive.';
      inputSchema = z.object({
        orderNumber: z.string().regex(/^[A-Z]-\d{4,}$/).describe('Order number, for example A-1001'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const baseUrl = env('ORDERS_API_URL');
        const apiKey = env('ORDERS_API_KEY');
        if (!baseUrl || !apiKey) throw new Error('ORDERS_API_URL and ORDERS_API_KEY are not set');

        const res = await fetch(`${baseUrl}/orders/${encodeURIComponent(input.orderNumber)}`, {
          headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
          signal: AbortSignal.timeout(10_000),
        });
        if (res.status === 404) return { found: false, orderNumber: input.orderNumber };
        if (!res.ok) throw new Error(`Orders API answered ${res.status} for ${input.orderNumber}`);

        const order = (await res.json()) as { status: string; carrier?: string; eta?: string };
        return { found: true, orderNumber: input.orderNumber, ...order };
      }
    }
    ```

    A tool has 180 seconds of wall time, and the end user is waiting for all of it, so 10 seconds is a generous ceiling for one request ([Platform limits](/ship/limits)). Turn an expected miss such as 404 into a result the model can explain; throw for everything else, because the error message is what the model sees. Return the fields the reply needs rather than the raw response: a result over 300,000 characters reaches the model truncated.
  </Step>

  <Step title="Run it locally">
    `lua test` runs `execute` on your machine with the values from `.env` and no model in the loop.

    ```bash theme={null}
    lua test --ci skill --name get_order_status --input '{"orderNumber":"A-1001"}'
    ```

    ```text Output theme={null}
    …
    📄 Loaded environment variables from .env file
    ✅ Selected tool: get_order_status
    Input: {
      "orderNumber": "A-1001"
    }

    🚀 Executing tool...
    ✅ Tool execution successful!

    Tool returned: Object — fields: found, orderNumber, status, carrier, eta
    Output:
    { found: true, orderNumber: 'A-1001', status: 'shipped', carrier: 'DHL', eta: '2026-09-15' }
    ```

    When the API is unreachable, the run still exits 0 and prints `{ status: 'error', error: 'fetch failed' }` after the stack trace; a thrown message appears as `error` the same way.
  </Step>

  <Step title="Store the key for production">
    Production code reads only the agent's server-side environment; `.env` is read by `lua test` and uploaded with sandbox versions, and never reaches production ([About environments](/concepts/environments)). Set the values once; no push or deploy is needed, and a changed value applies on the next call.

    ```bash theme={null}
    lua env production -k ORDERS_API_URL -v https://api.example.com
    lua env production -k ORDERS_API_KEY -v <key>
    ```

    `lua env production --list` prints the keys with values masked after the first four characters; the masking is display-only, and the interactive `lua env production` menu shows a variable's full value to any credential holding `knowledge:read`. To rotate a key, set the replacement value and revoke the old one at the provider.
  </Step>

  <Step title="Release">
    Push uploads a skill version and changes nothing for end users; the agent version you create and promote is what goes live, and it is also the rollback path.

    ```bash theme={null}
    lua push all --ci --force
    lua version create --ci -m "Add get_order_status"
    lua version promote <n>
    ```

    `lua version create` prints ``✓ Created v<n> (staged). Run `lua version promote v<n>` to deploy.``; `<n>` comes from that line, `promote` accepts `<n>` or `v<n>` and asks no confirmation, and in a script `n=$(lua version list --limit 1 --json --ci | jq -r '.[0].version')` reads it. `lua deploy skill --set-version <v>` serves that version from the agent's next turn, but the next `lua version promote` resets the skill to the promoted agent version's pin; push, create, and promote for a durable change. See [Release an agent to production](/ship/releasing).
  </Step>

  <Step title="Verify">
    Ask the live agent and read the tool's log. The reply quotes the status and carrier; the log's `Tool result` line holds the object the tool returned, or the error it threw.

    ```bash theme={null}
    lua chat -e production -m "Where is order A-1001?" -t
    lua logs --type skill --name <skill-name> --limit 5 --ci
    ```

    `--name` on `lua logs` is the name of the skill that holds the tool. A `Tool result { status: 'error', … }` with `ORDERS_API_URL and ORDERS_API_KEY are not set` means the production environment is missing a key.
  </Step>
</Steps>

## Options you may need

### Retry a failed request

Retry only requests that are safe to repeat: a `GET`, or a `POST` that carries an idempotency key your API honors. Retry once or twice with a short wait, only on a timeout or a 5xx status, and keep the total under the 180-second tool budget; a 4xx is your bug or the end user's input and won't change on retry. Never retry a write that has no idempotency key, because the first attempt may have succeeded.

### Send a body

Pass `method: 'POST'`, a `Content-Type: application/json` header, and `body: JSON.stringify(payload)`. The same timeout and status checks apply.

## If it isn't working

<AccordionGroup>
  <Accordion title="The production reply says the API key is not set, but lua test works">
    `lua test` and sandbox chat read `.env`; production reads only the server-side map. Set the key with `lua env production -k <KEY> -v <value>` and send the message again.
  </Accordion>

  <Accordion title="lua test prints { status: 'error', error: 'fetch failed' }">
    The host didn't answer: wrong `ORDERS_API_URL`, DNS, or a service that isn't running. The cause is in the stack trace printed before the result. Deployed code can't reach private-network addresses ([execution contexts](/concepts/execution-contexts)).
  </Accordion>

  <Accordion title="The tool times out under load">
    `AbortSignal.timeout` throws `TimeoutError` after the wait you set; the model reports the failure. Lower the timeout before adding a retry, and move long work to a [job](/build/schedule-a-job) or [workflow](/concepts/workflows) that reports back.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Environments" href="/concepts/environments">What .env, the sandbox, and production each read.</Card>
  <Card title="Store and search data" href="/build/store-and-search-data">Cache or record what your API returned.</Card>
  <Card title="lua env reference" href="/reference/cli/env">Every flag, masking, and the environment aliases.</Card>
  <Card title="env reference" href="/reference/sdk/env">Reading variables and the template form for workflows.</Card>
</Columns>
