The User API is a persistent, per-user key-value store that survives across conversations and sessions. Use it to store any data tied to a user — onboarding progress, workflow state, preferences, cart contents, verification status, or any custom fields your agent needs.Think of it as a schemaless user database: read and write any property, and it persists automatically. This makes it ideal for multi-step flows where your agent needs to remember where a user left off.
import { User } from 'lua-cli';// Get user instanceconst user = await User.get();// Access the read-only user profile (system-provided identity)console.log(user._luaProfile.fullName);console.log(user._luaProfile.emailAddresses); // A simple array of strings// Store ANY custom data — it persists across conversations and sessionsuser.onboardingStep = 'verified';user.lastProductViewed = 'SKU-123';user.collectedData = { company: 'Acme', plan: 'enterprise' };await user.save();
A new, read-only property user._luaProfile is now available on the user object. This provides a secure and reliable way to access core user identity information.
_luaProfile (Read-Only): Contains essential user data like userId, fullName, mobileNumbers, and emailAddresses.
The _lua prefix indicates this is a special, system-provided property.
This data is immutable; any attempts to change it will be silently ignored.
user.* (Mutable): Continue to use the main user object to store and manage any custom data your agent needs, such as preferences, shopping carts, or game scores.
Core User Identity
Access read-only data like user._luaProfile.userId and user._luaProfile.fullName.
Persistent Storage
Store any data on the user object — onboarding state, workflow progress, preferences, cart contents. Persists across all conversations and sessions.
To centralize core user information, user.userId is now deprecated. Please update your code to use user._luaProfile.userId. The old property will be removed in a future version.
The User object is not just for profile data — it is a persistent state store for building multi-step, stateful agent workflows. Any property you write to the user object persists across conversations, sessions, and even days or months.
Key insight for AI agents and developers: The User object is the primary way to maintain state across conversations. Use it to track onboarding progress, accumulate data across tool calls, and resume workflows exactly where the user left off.
import { LuaTool, User } from 'lua-cli';import { z } from 'zod';export class OnboardingTool implements LuaTool { name = 'handle_onboarding'; description = 'Guide user through onboarding steps, resuming where they left off'; inputSchema = z.object({ data: z.record(z.any()).optional().describe('Data collected in this step') }); async execute(input: any) { const user = await User.get(); // Read persisted state — survives across conversations! const step = user.onboardingStep || 'not_started'; switch (step) { case 'not_started': user.onboardingStep = 'collecting_info'; user.onboardingStartedAt = new Date().toISOString(); await user.save(); return { message: "Let's get you set up! What's your company name?" }; case 'collecting_info': user.companyName = input.data?.companyName; user.onboardingStep = 'awaiting_verification'; user.completedSteps = ['welcome', 'personal_info']; await user.save(); return { message: 'Great! Now let\'s verify your identity.' }; case 'awaiting_verification': user.verified = true; user.onboardingStep = 'choosing_plan'; user.completedSteps = [...(user.completedSteps || []), 'verification']; await user.save(); return { message: 'Verified! Which plan works best for you?' }; case 'choosing_plan': user.plan = input.data?.plan; user.onboardingStep = 'complete'; user.onboardingCompletedAt = new Date().toISOString(); user.completedSteps = [...(user.completedSteps || []), 'plan_selection']; await user.save(); return { message: `You're all set on the ${user.plan} plan!` }; case 'complete': return { message: `Welcome back! You completed onboarding on ${user.onboardingCompletedAt}.`, plan: user.plan, company: user.companyName }; } }}
No parameter: Returns current user from conversation context
string (userId): Retrieve a specific user by ID
{ email: string }: Look up user by email address
{ phone: string }: Look up user by phone number (with or without + prefix)
Required in: Webhooks, pre-defined LuaJob (no conversational context)Optional in: Tools, dynamic jobs (has conversational context)
Returns:UserDataInstance with proxy-based property access, or null if user not found (for email/phone lookup)
New in Latest Version: Look up users by email or phone! This is especially useful in webhooks where you receive contact info from external systems but don’t have the internal userId.
Understanding when userId is required vs optional:
Context
Method
Identifier Required?
Why
Tools
User.get()
❌ Optional
Has conversational context
Webhooks
User.get(userId) or User.get({ email })
✅ REQUIRED
No conversational context
LuaJob
User.get(userId) or User.get({ phone })
✅ REQUIRED
No conversational context
Jobs API
jobInstance.user()
❌ N/A
Automatic user context
Context Matters:
Tools: identifier is optional - defaults to current user in conversation
Webhooks: identifier is REQUIRED - use userId, email, or phone lookup
LuaJob (pre-defined): identifier is REQUIRED - use userId, email, or phone lookup
Jobs API (dynamic): Use jobInstance.user() instead - automatic context captured!
New! In webhooks, you can now look up users by email or phone if you don’t have the userId:
const user = await User.get({ email: '[email protected]' });const user = await User.get({ phone: '+1234567890' });
Examples:
Current User
Specific User (Webhooks/LuaJob)
Dynamic Jobs (Special Case)
Email/Phone Lookup
Get the current user from conversation context:
import { User } from 'lua-cli';// In your toolasync execute(input: any) { const user = await User.get(); // Direct property access console.log(user.name); // "John Doe" console.log(user.email); // "[email protected]" console.log(user.phone); // "555-0123" return { userName: user.name };}
Get a specific user by ID (REQUIRED in webhooks and pre-defined jobs):
import { User } from 'lua-cli';// In a webhook (NO conversational context)execute: async (event) => { // ⚠️ MUST provide userId in webhooks const customerId = event.data.object.metadata?.customerId; if (!customerId) { return { error: 'No customer ID provided' }; } const user = await User.get(customerId); // Send message to that specific user await user.send([{ type: 'text', text: `✅ Payment received: $${event.data.object.amount/100}` }]); return { notified: true };}
Required in:
✅ Webhooks - No conversational context
✅ LuaJob (pre-defined) - No conversational context
✅ Admin tools - Managing other users
NOT needed in:
❌ Tools - Use User.get() without userId
❌ Jobs API (dynamic) - Use jobInstance.user() instead
Dynamic jobs have automatic user context:
import { Jobs } from 'lua-cli';// Creating a dynamic job from a toolawait Jobs.create({ name: 'user-reminder', execute: async (jobInstance) => { // ✅ Use jobInstance.user() - automatic context! const user = await jobInstance.user(); await user.send([{ type: 'text', text: 'Reminder: Your meeting starts in 5 minutes!' }]); }});
Why it works: Dynamic jobs created from tools automatically capture the user context, so you don’t need to provide a userId.
Look up a user by email or phone number:
import { User } from 'lua-cli';// In a webhook receiving customer contact infoexecute: async (event) => { const { email, phone } = event.body; // Look up by email const userByEmail = await User.get({ email: '[email protected]' }); // Look up by phone (both formats work) const userByPhone = await User.get({ phone: '+1234567890' }); const userByPhone2 = await User.get({ phone: '1234567890' }); // Handle not found gracefully if (!userByEmail) { return { error: 'User not found' }; } // Update the user's data userByEmail.routingEnabled = true; await userByEmail.save(); return { success: true, userId: userByEmail._luaProfile.userId };}
Use cases:
✅ Webhooks - External systems send email/phone, not userId
✅ Admin tools - Operators search by contact info
✅ Integrations - Third-party systems don’t have Lua user IDs
Returns null if not found: Unlike User.get(userId) which throws on not found, email/phone lookup returns null. Always check for null before using the user object.
Save the current state of user data to the server. This is a convenience method that persists all changes made to the user instance.
user.save(): Promise<boolean>
Returns: Promise resolving to true if successfulExamples:
const user = await User.get();// Modify propertiesuser.name = "John Doe";user.email = "[email protected]";user.phone = "555-1234";// Save all changes at onceawait user.save();// Much cleaner than multiple update calls!
New in Latest Version: The save() method provides a simpler workflow - modify properties then save, rather than passing data to update().