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

# Declare connections in a workflow

> Name the integrations a workflow acts through by key so one definition resolves on any agent, and recover when a key has no connection

After this guide, your [workflow](/concepts/workflows) names the Stripe, GitHub, or Linear account it acts through by a key that resolves on whichever agent runs it, and a step whose key has no connection parks for a person instead of failing the run. Declare keys whenever a workflow will run on more than one agent or ship in an [agent template](/concepts/agent-templates); a literal connection id still works for a one-off.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* An [integration](/concepts/integrations) connected to the agent with `lua integrations connect --integration <type>`; see [Connect an integration](/integrations/connect).
* A workflow that compiles ([Author a workflow](/build/workflows/authoring)).

<Steps>
  <Step title="Declare the keys and reference them from steps">
    `connections` on `createWorkflow` lists one declaration per account the workflow acts through. A declared key then stands wherever a connection id is accepted: `requiredConnections` on a code, agent, or tool step, and `workspace.credentialsRef` for a Job-tier checkout ([Use the Job tier](/build/workflows/job-tier)).

    ```ts src/workflows/refund-approval.ts highlight={12,34} expandable theme={null}
    import { z } from 'zod';
    import { createStep, createWorkflow, fromInit, template, Integrations } from 'lua-cli';

    const RefundRequest = z.object({ chargeId: z.string(), amount: z.number() });

    const postRefund = createStep({
      id: 'postRefund',
      inputSchema: z.object({ approved: z.boolean(), editedPayload: RefundRequest.optional() }).passthrough(),
      outputSchema: z.object({ refundId: z.string().nullable() }),
      sideEffects: 'external',
      onError: 'park',
      requiredConnections: ['stripe'],
      async execute({ inputData, getStepResult, once }) {
        if (!inputData.approved) return { refundId: null };
        // The approval does not echo its payload back: read the edited copy, else the one it showed.
        const request = inputData.editedPayload ?? getStepResult<z.infer<typeof RefundRequest>>('request');
        const refundId = await once(`refund:${request.chargeId}`, async () => {
          const res = await Integrations.passthrough('stripe', {
            method: 'POST',
            path: '/v1/refunds',
            data: { charge: request.chargeId, amount: request.amount },
          });
          return (res.data as { id: string }).id;
        });
        return { refundId };
      },
    });

    export const refundApproval = createWorkflow({
      name: 'refund-approval',
      description: 'Ask a support lead to approve a refund, then post it through Stripe.',
      inputSchema: RefundRequest,
      outputSchema: z.object({ refundId: z.string().nullable() }),
      budget: { maxCredits: 5, maxDurationSeconds: 14 * 24 * 3600 },
      connections: [{ key: 'stripe', integrationType: 'stripe', required: true, description: 'Posts refunds' }],
    })
      .map({ chargeId: fromInit('chargeId'), amount: fromInit('amount') }, { id: 'request' })
      .approval('approveRefund', {
        title: 'Refund request',
        details: template('Refund ${initData.amount} on charge ${initData.chargeId}'),
        approver: { role: 'support-lead' },
        excludeInitiator: true,
        timeoutHours: 8,
        businessHours: { tz: 'Europe/London', calendar: 'mon-fri' },
        onTimeout: [{ escalateTo: 'org-admins', timeoutHours: 24 }, 'deny'],
        onDeny: 'continue',
        editablePaths: ['amount'],
        editedPayloadSchema: RefundRequest.extend({ amount: z.number().positive().max(500) }),
      })
      .then(postRefund)
      .commit();
    ```

    `key` matches `^[a-z][a-z0-9_-]{0,63}$` and is unique in the workflow; `integrationType` is the integration's catalog slug; `required` records that the workflow cannot run without it; `description` tells whoever connects the account what it is for. The declarations travel with every pushed version and are not part of the graph hash. The step's code still names the integration type: [`Integrations.passthrough`](/reference/sdk/integrations) relays the call through the agent's Unified.to connection of that type, sends `data` as JSON, and returns the provider's parsed body as `res.data`.
  </Step>

  <Step title="Compile">
    `lua compile` and `lua push workflow` refuse a `requiredConnections` entry or `credentialsRef` that looks like a key but is not declared, whether or not the workflow has a `connections` block:

    ```text Output theme={null}
    🔨 Compiling...❌ Compilation failed:
       src/workflows/refund-approval.ts:29 - connection-key-undeclared graph.2.requiredConnections 'stripe' is neither a connection id nor a declared connections[].key — declare it: connections: [{ key: 'stripe', integrationType: '<catalog slug, e.g. github>' }] and it resolves on any agent
    …
    ✖ compile_failed: Compilation failed — see errors above.
    ```

    The path names the graph entry (`graph.2` is the third entry in the chain). A declaration with a key outside the grammar, a duplicate key, or no `integrationType` is `connection-declaration-invalid`. A 24-character hex connection id is not a key and passes the compile; the platform verifies it at push.
  </Step>

  <Step title="Check per-environment values">
    Values that differ per agent but are not connections, such as an agent id or a timezone, use `env.template('KEY')` in the definition. `lua push workflow` resolves each key from the target environment; `env-overlay` shows what a version carries and whether each key is present, never the values.

    ```bash theme={null}
    lua workflows env-overlay lead-outreach -v latest
    ```

    ```text Output theme={null}
    🔐 env overlay — lead-outreach v1.0.3 (0 key(s); values are never returned)
       (no env.template() keys on this version)
       envOverlayHash: sha256-cj1:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
    ```

    A missing key prints `❌ <n> key(s) missing from the agent env (<KEY>)` and exits 1. Keys ending in `SECRET`, `TOKEN`, `KEY`, or `PASSWORD` are refused at build time (`env-template-secret-key`); read those with `env('KEY')` inside `execute`.
  </Step>

  <Step title="Verify how a key resolves">
    Push the workflow to an agent that has a Stripe connection and start a run. Before the first step that names the key is dispatched, the platform picks one connection and records the choice:

    * Candidates are the connections of the declared `integrationType` that belong to the agent, else the organization's. A person's own connection is never a candidate: a run acts as the agent, not as the user who started it.
    * One candidate is taken. Among several, the one whose account label equals the key wins; otherwise the step fails as ambiguous.
    * The key is resolved once per run, the first time any step needs it, appended to the events as `connection.resolved`, and printed by `lua workflows status <runId>` on a `Connections:` line (`stripe → <connectionId> (stripe, agent)`).

    On an agent with no Stripe connection, `postRefund` fails with `credentials_unresolved` and `Connect a stripe connection to this agent (or its org) for connection key 'stripe', then retry the step.`; with several unlabeled candidates the message lists them and asks you to label one `stripe`. Because the step declares `onError: 'park'`, the run waits on an exception gate: connect the integration, then `lua workflows retry-step <runId> --step postRefund` resumes it without losing completed work. A connection that resolves but whose credentials are no longer usable fails the step with `credentials_revoked`.
  </Step>
</Steps>

## Options you may need

### Connections in a Job-tier coding turn

`toolScope.connectionIds` on a Job-tier agent step mounts connections into the coding turn as MCP servers. Declared keys resolve there at run time, but this slot is not checked by `lua compile`, so a typo surfaces only when the step runs.

### Literal ids

A value that is a connection id never enters the resolver: it is mounted or minted as is, and a workflow with no `connections` block behaves as before. Prefer keys for anything you push to more than one agent.

## If it isn't working

<Accordion title="connection-key-undeclared">
  A `requiredConnections` entry or `credentialsRef` is a lowercase slug that no declaration names. Add `connections: [{ key: '<slug>', integrationType: '<type>' }]` to `createWorkflow`, or replace the slug with a connection id.
</Accordion>

<Accordion title="credentials_unresolved">
  No connection of the declared type on the agent or its organization, or several without one labeled with the key. Connect the integration or set one connection's display name to the key, then `lua workflows retry-step <runId> --step <id>`.
</Accordion>

<Accordion title="credentials_revoked">
  The resolved connection's credentials are no longer usable. Reconnect it with `lua integrations connect --integration <type>`, then retry the step.
</Accordion>

## Next steps

<Columns cols={2}>
  <Card title="Use the Job tier" href="/build/workflows/job-tier">How `credentialsRef` becomes a short-lived token for the checkout.</Card>
  <Card title="About integrations" href="/concepts/integrations">Unified.to integrations, scopes, and passthrough.</Card>
  <Card title="Operate runs" href="/build/workflows/operate-runs">Exception gates, `retry-step`, and `resolve-step`.</Card>
</Columns>
