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

# Identify users

> Read the current end user, store what you learn on their record, reach them from a webhook or job, and keep each account's data separate

After this guide, your tools know who they are talking to, remember what they learn, and can reach the same person from code that runs outside a conversation. [`User.get()`](/reference/sdk/user) is the current end user inside a tool; in a webhook, trigger, or job nobody is talking, so you pass an id ([execution contexts](/concepts/execution-contexts)). For records that aren't about one person, use [`Data`](/build/store-and-search-data).

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A skill on the agent to hold the tools ([Add a tool to a skill](/build/add-a-tool)).
* An idea of what you'll store per person: an account id, preferences, or progress through a flow.

<Steps>
  <Step title="Read the current end user">
    `User.get()` is typed `UserDataInstance | null`, so narrow it before use. The instance has two parts: `_luaProfile`, the platform's read-only identity, and your own fields, which live on the record directly.

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

    export default class GetProfileTool implements LuaTool {
      name = 'get_profile';
      description = "Read the current customer's name, contact details, and linked account.";
      inputSchema = z.object({});

      async execute() {
        const user = await User.get();
        if (!user) throw new Error('No end user in this context');
        return {
          userId: user._luaProfile.userId,
          name: user._luaProfile.fullName,
          emails: user._luaProfile.emailAddresses,
          phones: user._luaProfile.mobileNumbers,
          accountId: user.accountId ?? null,
        };
      }
    }
    ```

    The profile is the same record whichever [channel](/concepts/channels) the message came from: a sender whose phone number or email is already on a profile resolves to it, so what you store follows the person across channels. `_luaProfile` can't be written; assignments to it are ignored.
  </Step>

  <Step title="Store what you learn">
    `user.patch` sets and removes top-level fields in one request; `user.update` merges fields; direct assignments stay local until `user.save()`. The record is per agent and shared by the sandbox and production.

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

    export default class LinkAccountTool implements LuaTool {
      name = 'link_account';
      description = 'Link the customer to their Acme account once they have given a valid account number.';
      inputSchema = z.object({
        accountId: z.string().regex(/^acct_[a-z0-9]+$/).describe('Account number, for example acct_abc123'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const user = await User.get();
        if (!user) throw new Error('No end user in this context');
        await user.patch({
          set: { accountId: input.accountId, linkedAt: new Date().toISOString() },
          unset: ['pendingAccountId'],
        });
        return { linked: true, accountId: input.accountId };
      }
    }
    ```

    Store the `_luaProfile.userId` in your own system when you first meet a person; it is the key that finds them again from outside a conversation.
  </Step>

  <Step title="Reach the end user outside a conversation">
    A webhook, trigger, or job has no current end user: a bare `User.get()` fails there in deployed code and resolves to you under `lua test`. Pass the id you stored, or look the person up by email or phone; `null` means nobody matched.

    ```ts src/webhooks/AccountSuspendedWebhook.ts theme={null}
    import { LuaWebhook, User } from 'lua-cli';
    import { z } from 'zod';

    export default new LuaWebhook({
      name: 'account-suspended',
      description: 'Tells a customer that their account was suspended',
      bodySchema: z.object({
        userId: z.string().optional(),
        email: z.string().email().optional(),
        reason: z.string(),
      }),
      async execute({ body }) {
        const user = body.userId
          ? await User.get(body.userId)
          : body.email
            ? await User.get({ email: body.email })
            : null;
        if (!user) return { found: false };
        await user.send([{ type: 'text', text: `Your account was suspended: ${body.reason}` }]);
        return { found: true, userId: user._luaProfile.userId };
      },
    });
    ```

    `user.send` posts into the person's conversation on the channel they last used: WhatsApp, Messenger, Instagram, a Teams personal chat, MessageBird, or SMS, and the web widget always receives a live copy; a person whose last channel was Slack, Front, iMessage, or RCS can't be reached this way. In a deployed agent the call resolves `true` whether or not the message was delivered, and under `lua test` it throws on failure, so never branch on its return value; confirm delivery in `lua logs`. To pick a channel, reach someone with no prior conversation, or get a `deliveryId` for `Channels.getStatus`, use [`Channels.send`](/build/send-proactive-messages).
  </Step>

  <Step title="Keep each account's data separate">
    `Data` has no tenant concept, so put the account id on every entry you write and filter by it on every read. Take the id from the end user's record, never from a tool argument: an argument is whatever the model was told; the record is what your code verified.

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

    export default class ListAccountTicketsTool implements LuaTool {
      name = 'list_account_tickets';
      description = "List the open support tickets on the customer's linked account.";
      inputSchema = z.object({});

      async execute() {
        const user = await User.get();
        if (!user?.accountId) return { error: 'Ask the customer to link their account first.' };
        const page = await Data.get(
          'tickets',
          { accountId: user.accountId, status: { $ne: 'closed' } },
          1,
          50
        );
        return page.data.map((entry) => ({ id: entry.id, ...entry.data }));
      }
    }
    ```

    To hide a whole skill until an account is linked, put the same `accountId` check in a skill `condition` ([Write skill context](/build/write-skill-context)).
  </Step>

  <Step title="Register the skill and the webhook">
    Tools are compiled only from a skill on `LuaAgent.skills`, and the webhook only from `LuaAgent.webhooks`.

    ```ts src/skills/accounts.skill.ts theme={null}
    import { LuaSkill } from 'lua-cli';
    import GetProfileTool from './tools/GetProfileTool';
    import LinkAccountTool from './tools/LinkAccountTool';
    import ListAccountTicketsTool from './tools/ListAccountTicketsTool';

    export default new LuaSkill({
      name: 'accounts',
      description: 'Account linking and per-account ticket lookups',
      context:
        'Call link_account once the customer gives an account number of the form acct_…; ' +
        'call list_account_tickets only after the account is linked. Never ask for a password.',
      tools: [new GetProfileTool(), new LinkAccountTool(), new ListAccountTicketsTool()],
    });
    ```

    The agent file below is the quickstart's; if yours differs, add only the highlighted lines to your own `LuaAgent`.

    ```ts src/index.ts highlight={3-4,9-10} theme={null}
    import { LuaAgent } from 'lua-cli';
    import weatherSkill from './skills/weather.skill';
    import accountsSkill from './skills/accounts.skill';
    import accountSuspended from './webhooks/AccountSuspendedWebhook';

    const agent = new LuaAgent({
      name: 'docs-quickstart',
      persona: 'You are a weather assistant. Answer in one or two sentences.',
      skills: [weatherSkill, accountsSkill],
      webhooks: [accountSuspended],
    });
    ```
  </Step>

  <Step title="Verify">
    `lua test` runs as you, the signed-in developer, so the write lands on your own record for this agent and `get_profile` returns your profile.

    ```bash theme={null}
    lua test --ci skill --name link_account --input '{"accountId":"acct_abc123"}'
    ```

    ```text Output theme={null}
    …
    🚀 Executing tool...
    ✅ Tool execution successful!

    Tool returned: Object — fields: linked, accountId
    Output:
    { linked: true, accountId: 'acct_abc123' }
    ```

    Run `lua test --ci skill --name get_profile --input '{}'` next: `accountId` reads `acct_abc123`, and `emails` lists the address you signed in with. Then run the webhook with an address nobody has.

    ```bash theme={null}
    lua test --ci webhook --name account-suspended --input '{"body":{"email":"nobody@example.com","reason":"Unpaid invoice"}}'
    ```

    ```text Output theme={null}
    …
    🚀 Executing webhook...
    Query: {}
    Headers: {}
    Body: {
      "email": "nobody@example.com",
      "reason": "Unpaid invoice"
    }

    ✅ Webhook execution successful!

    Webhook returned: Object — fields: found
    Output:
    { found: false }
    ```

    With your own `userId` from `get_profile` instead, `user.send` throws `Failed to send message` unless you have a conversation on a channel, and the run prints `{ status: 'error', error: 'Failed to send message' }`.
  </Step>

  <Step title="Release">
    `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 "Add account linking"
    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.
  </Step>
</Steps>

## Options you may need

### Look an end user up by phone

`User.get({ phone: '+15551234567' })` accepts the number with or without the leading `+`. When both `email` and `phone` are given, `email` wins. To read the person's turns with this agent, call `User.getChatHistory()` ([User reference](/reference/sdk/user#getchathistory)).

## When two users merge

One person can end up as two Lua users. Lua staff can merge them: one user is kept, and the other is absorbed into it. You can't start a merge from the SDK or the CLI.

The merge moves these from the absorbed user to the kept user:

* Login methods: email, phone, and sign-in.
* Channel identities, such as WhatsApp, Slack, and web widget sessions.
* Organization memberships and access. Where both users had access, the higher role wins.
* API credentials and sessions.
* Conversation history with every agent. When both users talked to the same agent, the two conversations become one, in time order, and the agent remembers what either of them said.

The absorbed user's id keeps working: `User.get(absorbedUserId)`, like any other lookup by id, returns the kept user. Ids already stored in your records still resolve, but new data arrives under the kept user's id. The fields your agent saved on the user with `user.update()` move too. When both users had saved fields for the same agent, the kept user's values win and fields only the absorbed user had are added. Lua doesn't rewrite user ids inside your own data, such as `Data` entries that hold a user id or records in your CRM. Update those when the `user.merged` event arrives.

### Merge events

Subscribe a webhook to `user.merged` and `user.unmerged` the same way as the other [platform events](/concepts/webhooks#platform-event-subscriptions). `lua webhooks subscribe` accepts them from lua-cli 3.46.0; 3.45.0 and older refuse them.

```bash theme={null}
lua webhooks subscribe --webhook-name <name> --event user.merged
lua webhooks subscribe --webhook-name <name> --event user.unmerged
```

Lua sends `user.merged` once to each agent the absorbed user had talked to. `event.body` holds these fields:

| Field | Description |
| - | - |
| `eventType` | `user.merged` or `user.unmerged` |
| `mergeId` | The merge's id |
| `absorbedUserId` | The user that was absorbed |
| `keptUserId` | The user that was kept |
| `knewKept` | `true` when this agent also knew the kept user |
| `at` | When the event was sent, as an ISO 8601 time |

`event.execution.eventId` is the merge id for `user.merged` and `<mergeId>-undo` for `user.unmerged`. Key your handler on it, so a repeated delivery changes nothing.

### Undo

Lua staff can undo a merge. Undo moves back exactly what the merge moved, and Lua sends `user.unmerged` with the same fields. Anything written after the merge, such as new messages or new data, stays with the kept user.

## If it isn't working

<AccordionGroup>
  <Accordion title="User.get() fails in a webhook or job">
    There is no current end user in those contexts. Pass the id you stored, `User.get(userId)`, or look the person up with `{ email }` or `{ phone }` and handle `null`.
  </Accordion>

  <Accordion title="The record is empty on a channel where the user already talked to the agent">
    Neither the phone number nor the email was on the profile yet, so the channel identity resolved to a new one. Ask for a verified contact detail, or store your own key in both places and look up by it. Lua staff can also merge the two users ([When two users merge](#when-two-users-merge)).
  </Accordion>

  <Accordion title="An unset field is still there">
    `update()` only merges. Use `patch({ unset: ['field'] })` or `unset('field')`, which remove top-level fields; a field set to `null` is stored as `null`.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="User reference" href="/reference/sdk/user">get, lookups, patch, send, Inbox, and every type.</Card>
  <Card title="Execution contexts" href="/concepts/execution-contexts">What the current user resolves to in each place your code runs.</Card>
  <Card title="Send proactive messages" href="/build/send-proactive-messages">Reach a person on a chosen channel, and the WhatsApp 24-hour window.</Card>
  <Card title="Store and search data" href="/build/store-and-search-data">Records that aren't about one person.</Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.