> ## 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.

# Compose agents

> Hand part of a task to another agent with Agents.invoke, and make a one-off model call with AI.generate

After this guide, a tool or job in one [agent](/concepts/agents) hands part of its work to another agent, or makes a plain model call when no agent is needed. `Agents.invoke` runs a full turn on the target agent, with its persona, skills, processors, and governance; `AI.generate` calls a model with a prompt and nothing else. For routing between agents set up in the admin dashboard, use a [Space](/concepts/spaces).

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A project created with `lua init` and signed in with `lua auth configure` ([Install and sign in](/get-started/install)).
* The target agent, in the same organization, released and answering in `lua chat`, or this agent's own ID, to invoke itself.

<Steps>
  <Step title="Find the target agent's ID and store it">
    `lua agents --json --ci` prints an array of organizations, each with an `agents` array of `agentId` and `name`; your own project's ID is `project.agentId` in `lua status --json --ci`. Keep the ID in an environment variable: `lua test` reads `.env`, deployed code reads the production variables, and sandbox chat uploads the `.env` values with the sandbox version.

    ```bash theme={null}
    lua agents --json --ci | jq -r '.[].agents[] | "\(.agentId)\t\(.name)"'
    lua env production -k SALES_AGENT_ID -v <agent-id>
    lua env sandbox -k SALES_AGENT_ID -v <agent-id>
    ```

    ```text Output theme={null}
    baseAgent_agent_1770243756469_3or9n7xvf	Placeholder Agent
    …
    ```
  </Step>

  <Step title="Invoke it from a tool">
    Inside a conversation the turn runs as the current end user. `threadId` isolates the target agent's conversation; without it the invocation joins the end user's default thread with that agent. When the target's preprocessor or governance stops the turn, the call throws with `code` set to `PREPROCESSOR_BLOCKED` or `GOVERNANCE_BLOCKED` and the block text as the message; catch that case rather than treating it as a tool failure.

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

    export default class DraftReplyTool implements LuaTool {
      name = 'draft_reply';
      description = 'Ask the sales agent to draft a reply about pricing or contracts';
      inputSchema = z.object({
        question: z.string(),
        accountId: z.string().describe('Account the question is about'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const salesAgentId = env('SALES_AGENT_ID');
        if (!salesAgentId) throw new Error('SALES_AGENT_ID is not set');

        try {
          const result = await Agents.invoke(salesAgentId, {
            prompt: `Draft a short reply to this customer question: ${input.question}`,
            threadId: `account-${input.accountId}`,
            timeoutMs: 60_000,
          });
          return { draft: result.text, toolsUsed: result.toolsUsed ?? [] };
        } catch (error) {
          // The sales agent's preprocessor or governance refused the turn.
          const code = (error as { code?: string }).code;
          if (code === 'PREPROCESSOR_BLOCKED' || code === 'GOVERNANCE_BLOCKED') {
            return { draft: null, blockedBy: code, reason: (error as Error).message };
          }
          throw error;
        }
      }
    }
    ```
  </Step>

  <Step title="Invoke it from a job">
    A [job](/concepts/jobs) has no current user. Pass `userId` so the turn runs as that user, with their profile and history; omit it and the turn runs in system scope, with no end user and no stored conversation ([Execution contexts](/concepts/execution-contexts)). `user_abc123` is a placeholder for a stored Lua user ID ([Identify users](/build/identify-users)).

    ```ts src/jobs/WeeklyReviewJob.ts theme={null}
    import { LuaJob, Agents, env } from 'lua-cli';

    export default new LuaJob({
      name: 'weekly-review',
      description: 'Every Monday, have the sales agent review the pipeline for the account owner',
      schedule: { type: 'cron', expression: '0 7 * * 1', timezone: 'Europe/London' },
      timeout: 300,
      metadata: { ownerUserId: 'user_abc123' },
      async execute(job) {
        const salesAgentId = env('SALES_AGENT_ID');
        if (!salesAgentId) throw new Error('SALES_AGENT_ID is not set');

        // A job has no current user, so name the user the turn should run as.
        const result = await Agents.invoke(salesAgentId, {
          prompt: 'Review open deals and message me a summary of anything at risk.',
          userId: job.metadata.ownerUserId,
          threadId: 'weekly-review',
          timeoutMs: 240_000,
        });
        return { finishReason: result.finishReason, toolsUsed: result.toolsUsed };
      },
    });
    ```
  </Step>

  <Step title="Make a one-off model call">
    `AI.generate` with an options object returns `text`, `finishReason`, and `usage`; `model` pins a code from `lua models list --json --ci` ([About models](/concepts/models)), and without it the platform default answers. With `structuredOutput.schema`, a JSON Schema whose top-level type is `object`, the parsed value lands on `output` when the model finishes normally; it is not validated, so check it before use.

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

    const Classification = z.object({
      category: z.enum(['billing', 'bug', 'feature_request', 'other']),
      urgency: z.enum(['low', 'medium', 'high']),
      summary: z.string(),
    });

    export default class ClassifyTicketTool implements LuaTool {
      name = 'classify_ticket';
      description = 'Classify a support message by category and urgency';
      inputSchema = z.object({ text: z.string() });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const result = await AI.generate({
          model: 'openai/gpt-5.4-mini',
          system: 'You classify customer support messages. Answer with JSON only.',
          prompt: input.text,
          temperature: 0,
          structuredOutput: {
            schema: {
              type: 'object',
              properties: {
                category: { type: 'string', enum: ['billing', 'bug', 'feature_request', 'other'] },
                urgency: { type: 'string', enum: ['low', 'medium', 'high'] },
                summary: { type: 'string' },
              },
              required: ['category', 'urgency', 'summary'],
              additionalProperties: false,
            },
          },
        });
        const parsed = Classification.safeParse(result.output);
        if (!parsed.success) return { error: 'Model returned an unexpected shape', raw: result.text };
        return parsed.data;
      }
    }
    ```
  </Step>

  <Step title="Test locally">
    `lua test skill` runs a tool on your machine; `AI.generate` and `Agents.invoke` still reach the platform. Locally, `Agents.invoke` runs as you with your developer credentials, drops `userId`, `model`, and `timeoutMs`, and reports a blocked turn as `finishReason` `preprocessor_blocked` or `governance_blocked` instead of throwing.

    ```bash theme={null}
    lua test skill --name classify_ticket --input '{"text":"I was charged twice this month and need it fixed today."}' --ci
    ```

    ```text Output theme={null}
    ✅ Selected tool: classify_ticket
    Input: {
      "text": "I was charged twice this month and need it fixed today."
    }
    🚀 Executing tool...
    ✅ Tool execution successful!

    Tool returned: Object — fields: category, urgency, summary
    Output:
    {
      category: 'billing',
      urgency: 'high',
      summary: 'Customer reports being charged twice this month and requests an immediate fix today.'
    }
    ```

    Without `model`, the platform default answered the same input in another shape (`"category": "Billing"`, a `sub_category`, no `summary`), which is why the tool validates `output` and falls back to `text`.
  </Step>

  <Step title="Release it">
    `lua push` uploads a version and changes nothing for end users; `lua version create` snapshots the agent; `lua version promote <n>` makes that snapshot live and is also the rollback path ([Release an agent to production](/ship/releasing)).

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

    `lua version create` prints `✓ Created v<n> (staged)`; in a script, `n=$(lua version list --limit 1 --json --ci | jq -r '.[0].version')` then `lua version promote "$n"`. `lua push all` exits 0 even when a primitive fails; check its output for `component(s) failed to push`.
  </Step>

  <Step title="Verify">
    Ask a question the tool delegates, then read the logs.

    ```bash theme={null}
    lua chat -e production -m "What would a two-year contract cost account acct_123?"
    lua logs --type skill --limit 5
    ```

    The reply contains the sales agent's draft, and the log shows `draft_reply` running. The target agent records the delegated turn on the thread you named; `lua logs --agent-id <agent-id> --type all --limit 5` shows it when you administer that agent.
  </Step>
</Steps>

## Options you may need

### Set a timeout that fits the caller

`timeoutMs` defaults to 120 000 ms. A tool, webhook, or processor is itself stopped at 180 seconds, so a larger value buys nothing there; a job runs up to its own `timeout` (300 seconds by default, 600 at most).

### Override the target's persona for one turn

`systemPrompt` replaces the target agent's persona for that invocation only, and `runtimeContext` appends context to the request; the target's skills and processors still apply. `Agents.invoke(agentId, 'a plain prompt')` returns only the reply text.

### Shape a plain call

`messages` replaces `prompt` with a full message array; `maxOutputTokens` caps the answer.

## If it isn't working

<AccordionGroup>
  <Accordion title="The call throws PREPROCESSOR_BLOCKED or GOVERNANCE_BLOCKED">
    **Cause** The target agent's preprocessor or governance policy stopped the turn; the error's `message` is the block text. **Fix** Catch the error as in the example, read `lua logs --type preprocessor` on the target agent, and adjust its rules or your prompt.
  </Accordion>

  <Accordion title="The invocation times out">
    **Cause** The target took longer than `timeoutMs`, or your calling context hit its own budget first. **Fix** Raise `timeoutMs` up to the caller's limit, move the call into a job, or narrow the prompt.
  </Accordion>

  <Accordion title="output is undefined or does not match the schema">
    **Cause** `output` is set only when the model finishes with `stop`, and the parsed JSON is not validated against your schema. **Fix** Validate with zod as in the example, fall back to `text`, and try a different `model`.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Agents reference" href="/reference/sdk/agents">Every option and the output shape.</Card>
  <Card title="AI reference" href="/reference/sdk/ai">`AI.generate` inputs, outputs, and structured output.</Card>
  <Card title="About Spaces" href="/concepts/spaces">Routing between agents, set up in the admin dashboard.</Card>
  <Card title="Workflow quickstart" href="/build/workflows/quickstart">Agent steps with retries, approvals, and budgets.</Card>
</Columns>
