# AI Agent Building Guide Source: https://docs.heylua.ai/ai-guide Complete reference for AI Agents and AI Coding IDEs (Cursor, Windsurf, GitHub Copilot) to build, test, and deploy agents on the Lua platform ## Purpose This guide is for AI agents and coding IDEs that build, test, and deploy agents on the Lua platform. The build commands use non-interactive flags where practical. Authentication is the exception: keep emails, OTPs, and credentials out of the AI conversation. ## Prerequisites * Node.js 18+ installed * npm, yarn, or pnpm * Lua CLI installed: `npm install -g lua-cli` * Authentication configured (see below) ## High-Level Workflow ``` Authenticate → lua init → Write Code → lua test → iterate → lua chat sandbox → push → version create → version promote ↓ (Optional) lua integrations connect → Agent gets third-party tools instantly ``` After **`lua init`**, the CLI surfaces a suggested build loop: **compile → test → chat (sandbox) → push and deploy**. Treat that sequence as your default checklist before iterating on agent code. Once the agent has release history, prefer `lua version create` + `lua version promote` as your release step instead of `lua deploy` — see [§7](#7-push-deploy-and-agent-versions) for the full distinction. *** ## 1. Authentication Authenticate before you run a command that calls the Lua platform. ### New login Ask the user to open a private terminal outside the AI conversation and run: ```bash theme={null} lua auth configure ``` For email login, the CLI saves a renewable first-party session. It reads the user's current organizations and agents when commands run. Agent selection belongs to each project's `lua.skill.yaml`. Do not ask the user for their email address, OTP, or saved credential. Continue after the user confirms that setup finished. ### Existing credential `lua auth configure` also accepts an existing scoped or non-dotted legacy API key. The CLI validates and saves the supplied value unchanged. It does not rotate or revoke the key. If the environment already contains a working `LUA_API_KEY`, do not replace it. ### CI and unattended jobs Create a scoped credential in **Settings → API Keys**. Store the secret in the CI provider, then expose it to the job: ```bash theme={null} # The CI runner injects LUA_API_KEY from its secret manager. lua push all --ci --force --auto-deploy ``` The CLI reads credentials in this order: 1. `LUA_API_KEY`; if it is not exported, the CLI loads it from the current project's `.env` 2. The renewable session for the active Lua environment 3. `~/.lua-cli/credentials` Existing legacy credentials remain supported indefinitely. For CI and unattended jobs, use a scoped key with only the organizations, agents, and role that the job needs. *** ## 2. Initialization (`lua init`) **Run `lua init` only ONCE per project.** After initialization, you have your agent and codebase - work on it from there. ### Two Modes #### New Agent (`--agent-name`) Creates a brand new agent from scratch: ```bash theme={null} # Create agent in existing organization lua init --agent-name "My Agent" --org-id org_abc123 # Create agent + new organization lua init --agent-name "My Agent" --org-name "My Company" # With example code included lua init --agent-name "My Agent" --org-id org_abc123 --with-examples ``` #### Existing Agent (`--agent-id`) Links to an agent that already exists: ```bash theme={null} lua init --agent-id agent_abc123 # Override existing project lua init --agent-id agent_abc123 --force ``` **Important limitation**: Existing code/primitives on the server CANNOT be pulled down to local codebase. Bundling is one-way only (local → server). Use this mode to start fresh development on an existing agent. ### Non-Interactive Flags | Flag | Description | | --------------------- | ------------------------- | | `--agent-id ` | Use existing agent | | `--agent-name ` | Name for new agent | | `--org-id ` | Existing organization ID | | `--org-name ` | Create new organization | | `--with-examples` | Include example code | | `--force` | Override existing project | ### Project Structure Created ``` project/ ├── lua.skill.yaml # Config manifest (managed by CLI, don't edit manually) ├── package.json ├── tsconfig.json ├── .env # Local environment variables └── src/ └── index.ts # LuaAgent configuration ``` *** ## 3. Debugging with `lua logs` (read this BEFORE you write tools) **This section is promoted to the top because every AI builder needs the post-deploy loop in their head before they write a single tool.** All `console.log`, `console.error`, etc. output from your tool code shows up in `lua logs`. The CLI also prints a `✨ Tip:` line at the end of every push/deploy/chat/sync/compile/test pointing at the right `lua logs --type X --limit N` command for what you just ran. Follow it. The single canonical post-deploy loop is: ``` lua push --auto-deploy → lua chat -m "test" → lua logs --type X --limit 10 ``` For the full canonical guide, see [Debugging your agent — the post-deploy loop](/cli/debugging). ### The "post-test-always-check" pattern After every `lua chat -m "test"`, immediately run: ```bash theme={null} lua logs --type agent_error --limit 5 ``` If the count is **zero**, the test passed cleanly. If **non-zero**, fix the error before running another test message — most pipeline errors (billing, validation, LLM provider failures) don't surface in the chat response itself. The CLI helps by running a quiet `agent_error` probe automatically after every `lua chat` turn — when new errors fire, you'll see: ``` ⚠️ 2 new agent error(s) during this turn — run `lua logs --type agent_error --limit 2` to inspect. ``` Set `LUA_NO_HINTS=1` to silence all post-action hints. ### Post-deploy verification recipe After deploying, always run this 3-line check: ```bash theme={null} lua push all --force --auto-deploy lua chat -m "verify deploy" -e production -t prod-verify --clear lua logs --type agent_error --limit 5 # should be empty ``` If step 3 returns entries timestamped after your deploy, **don't walk away** — read them and decide whether to roll back. **Important:** `lua logs` returns **zero** results right after deploy until something executes. Generate traffic first, for example: ```bash theme={null} lua chat -e production -m "test" ``` Then inspect: ```bash theme={null} lua logs --type skill --limit 10 ``` ### `lua logs` quick reference ```bash theme={null} # View all logs lua logs --type all --limit 50 # Filter by type (the CLI suggests the right --type after each command) lua logs --type skill --name mySkill --limit 10 lua logs --type job --name healthCheck --json lua logs --type webhook --limit 20 lua logs --type agent_error --limit 10 # post-chat canary lua logs --type mcp --limit 20 lua logs --type rag --limit 10 lua logs --type runtime --limit 20 # Filter by user (for user-reported issues) lua logs --type all --user-id user_abc123 --limit 50 ``` ### Filter types | Type | Description | | ---------------- | ------------------------------------------------------------------------ | | `all` | All logs | | `skill` | Skill/tool executions | | `job` | Job executions | | `webhook` | Webhook executions | | `preprocessor` | PreProcessor executions | | `postprocessor` | PostProcessor executions | | `mcp` | MCP tool executions | | `runtime` | Agent runtime / LLM SDK / framework-level logs (was previously `mastra`) | | `rag` | Knowledge-base / RAG retrieval logs | | `device` | Device command executions | | `device-trigger` | Device trigger handler executions | | `user_message` | User messages | | `agent_response` | Agent responses | | `agent_error` | Pipeline errors — the signal to watch after a `lua chat` test | | `calls` | Voice call records | *** ## 4. Agent Configuration (LuaAgent) The main configuration lives in `src/index.ts`: ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; export const agent = new LuaAgent({ name: 'my-agent', persona: `You are a helpful assistant. Your role: - Help users with their tasks - Provide accurate information Communication style: - Friendly and professional - Clear and concise`, skills: [mySkill], // Optional components: // webhooks: [myWebhook], // jobs: [myJob], // preProcessors: [myPreProcessor], // postProcessors: [myPostProcessor], }); ``` ### Key Properties | Property | Required | Description | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | Agent identifier | | `persona` | Yes | Personality, behavior, capabilities, limitations | | `model` | No | AI model to use — string like `'google/gemini-2.5-flash'` or a function `(request) => string` for dynamic selection | | `skills` | Yes | Array of LuaSkill instances | | `webhooks` | No | HTTP endpoints for external events | | `jobs` | No | Scheduled cron tasks | | `preProcessors` | No | Message filters before agent processes | | `postProcessors` | No | Response formatters after agent responds | | `mcpServers` | No | External MCP tool servers (also auto-created via `lua integrations`) | *** ## 5. Building Components ### Skills & Tools A **Skill** is a collection of related tools: ```typescript theme={null} import { LuaSkill } from 'lua-cli'; const mySkill = new LuaSkill({ name: 'my-skill', description: 'Brief description of the skill', context: `Detailed instructions for when to use these tools. - Use tool_a when user asks about X - Use tool_b when user wants to do Y`, tools: [new ToolA(), new ToolB()] }); ``` A **Tool** is a single function the AI can call: ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; class GetWeatherTool implements LuaTool { name = 'get_weather'; description = 'Get current weather for a city'; inputSchema = z.object({ city: z.string().describe('City name'), units: z.enum(['metric', 'imperial']).optional().default('metric') }); async execute(input: z.infer) { const { city, units } = input; // Implementation here return { temperature: 22, condition: 'sunny', city }; } } ``` ### Webhooks HTTP endpoints for external events: ```typescript theme={null} import { LuaWebhook } from 'lua-cli'; import { z } from 'zod'; const paymentWebhook = new LuaWebhook({ name: 'payment-webhook', description: 'Handle Stripe payment events', bodySchema: z.object({ type: z.string(), data: z.any() }), execute: async (event) => { const { body } = event; // Handle the webhook return { received: true }; } }); ``` ### Jobs Scheduled cron tasks: ```typescript theme={null} import { LuaJob } from 'lua-cli'; const dailyReport = new LuaJob({ name: 'daily-report', description: 'Generate daily report', schedule: { type: 'cron', expression: '0 9 * * *' }, // 9 AM daily execute: async (job) => { // Generate report return { status: 'completed' }; } }); ``` ### PreProcessors & PostProcessors ```typescript theme={null} import { PreProcessor, PostProcessor } from 'lua-cli'; const profanityFilter = new PreProcessor({ name: 'profanity-filter', description: 'Filter inappropriate content', execute: async (user, messages, channel) => { // Return { action: 'block', response } to block, or { action: 'proceed' } to continue const text = messages.map(m => m.type === 'text' ? m.text : '').join(' '); if (containsProfanity(text)) { return { action: 'block', response: 'Message blocked due to inappropriate content.' }; } return { action: 'proceed' }; } }); const addDisclaimer = new PostProcessor({ name: 'add-disclaimer', description: 'Add legal disclaimer to responses', execute: async (user, message, response, channel) => { return { modifiedResponse: response + '\n\n_This is not legal advice._' }; } }); ``` *** ## 6. Testing Strategy This is one of the most important sections. Understanding when to use `lua test` vs `lua chat` is critical. ### `lua test` - Isolated Component Testing **Purpose**: Unit test for individual blocks (tools, jobs, webhooks, processors) **How it works**: * Creates a local VM and executes code directly * **No AI involved** - just runs the `execute` function * Fast - no network latency, no AI processing time **Use for**: Quick testing, quick debugging, building components in isolation **Input format**: Must match the tool's `inputSchema` exactly ```bash theme={null} # Test a tool lua test skill --name get_weather --input '{"city": "London"}' # Test a webhook lua test webhook --name payment-webhook --input '{"query": {}, "headers": {}, "body": {"type": "payment.completed"}}' # Test a job lua test job --name daily-report # Test a preprocessor lua test preprocessor --name profanity-filter --input '{"message": "hello", "channel": "web"}' # Test a postprocessor lua test postprocessor --name add-disclaimer --input '{"message": "hi", "response": "hello", "channel": "web"}' ``` ### `lua chat` - Live Agent Testing **Purpose**: Real agent requests involving AI **How it works**: * HTTP requests for streaming/generating responses * Full integration - multiple components may execute (skills, tools, pre/post-processors) **Two modes**: | Mode | Description | | ------------ | ----------------------------------------------------------- | | `sandbox` | Free, unbilled, can override local code without push/deploy | | `production` | Uses pushed and deployed versions | ```bash theme={null} # Test in sandbox (uses local code) lua chat -e sandbox -m "What's the weather in London?" # Test in production (uses deployed code) lua chat -e production -m "What's the weather in London?" ``` ### Key Difference | Command | What it does | AI involved? | Speed | | ---------- | ------------------------------------ | ------------ | ------ | | `lua test` | Runs execute function directly in VM | No | Fast | | `lua chat` | Full agent request with AI | Yes | Slower | ### Thread Isolation for Testing **Recommended for AI agents running automated tests.** Use `--thread` to scope each chat session to an isolated conversation context. This prevents test state from leaking between runs — no need to call `lua chat clear` between tests. Each `--thread` creates a completely independent conversation context. This also means **multiple tests can run concurrently** (5-10 at a time) without interfering with each other. ```bash theme={null} # Scope to an explicit thread ID lua chat -e sandbox -m "Test scenario A" --thread scenario-a # Auto-generate a UUID thread (printed at start so you can log/reuse it) lua chat -e sandbox -m "Test scenario B" --thread # Isolated test with automatic cleanup after response lua chat -e sandbox -m "Test scenario C" -t scenario-c --clear # Run 10 isolated tests sequentially, each with a clean context for i in $(seq 1 10); do lua chat -e sandbox -m "test scenario $i" -t "test-$i" --clear done ``` **`--thread` flags:** | Flag | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `-t, --thread [id]` | Scope to a named thread. Omit the ID to auto-generate a UUID. Thread ID is printed at session start. | | `--clear`, `--clear-thread` | Clear the thread's history when the session ends (interactive: on exit, non-interactive: after response). Requires `--thread`. | ### Concurrent Testing Because each `--thread` is a fully isolated conversation, you can run multiple `lua chat` invocations in parallel without any state collision. This is especially useful for AI agents that need to validate multiple conversation scenarios at once: ```bash theme={null} # Run 5 tests in parallel (background jobs) lua chat -e sandbox -m "test flow A" -t test-a --clear & lua chat -e sandbox -m "test flow B" -t test-b --clear & lua chat -e sandbox -m "test flow C" -t test-c --clear & lua chat -e sandbox -m "test flow D" -t test-d --clear & lua chat -e sandbox -m "test flow E" -t test-e --clear & wait # wait for all to complete ``` ### Clearing Conversation History (`lua chat clear`) ```bash theme={null} # Clear all conversation history lua chat clear --force # Clear specific user's history lua chat clear --user user@email.com --force lua chat clear --user +1234567890 --force lua chat clear --user user_abc123 --force # Clear a specific thread's history lua chat clear --thread my-test-scenario --force ``` Without `--user`, the command clears only the authenticated caller's history. `--user` requires `org:manage` for the agent. Organization-admin grants do not cascade to private agents; organization owners and sufficient explicit agent grants retain access under the shared authorization rules. Only users associated with that agent can be targeted. **When to use `lua chat clear` vs `--thread`**: | Approach | When to use | | ------------------------ | ------------------------------------------------------------ | | `lua chat clear --force` | Reset all history before a new development session | | `--thread ` | Isolate individual test runs without touching other contexts | | `--clear` | Auto-clean up thread history at end of each automated test | ### Recommended Development Workflow ```bash theme={null} # 1. Write/modify component code # 2. Quick isolated test lua test skill --name my_tool --input '{"param": "value"}' # 3. Check logs if issues lua logs --type skill --name my_tool --limit 5 # 4. Iterate until component works # 5. Test with full agent in an isolated thread (no need to clear history) lua chat -e sandbox -m "Test message" -t dev-session # 6. Run a suite of scenario tests concurrently lua chat -e sandbox -m "scenario 1" -t s1 --clear & lua chat -e sandbox -m "scenario 2" -t s2 --clear & wait # 7. When ready: push and deploy # 8. Verify in production lua chat -e production -m "Test message" -t prod-verify --clear ``` *** ## 7. Push, Deploy, and Agent Versions Understanding this distinction is critical. Many users — and AI agents following this guide — confuse these commands. There are three commands involved in getting code live, and they do three different things: ### `lua push` - Upload Code to Server * Compiles local code and uploads to Lua platform server/database * Mints a new immutable **version** of that one primitive * Code now exists on server, not just locally * **Activates nothing.** Does NOT make it live yet. ```bash theme={null} # Push a specific skill with version lua push skill --name mySkill --set-version 1.0.0 --force # Push all components lua push all --force # Push and immediately deploy lua push all --force --auto-deploy ``` ### `lua version create` + `lua version promote` - Recommended Release Flow * `lua version create` snapshots the latest **pushed** version of every primitive (skills, webhooks, jobs, processors, triggers, persona, voice, MCP config, model) into a single agent version. * `lua version promote ` atomically activates that snapshot — every primitive switches at once, with no window where some are old and some are new. * This is the recommended way to ship a release, especially when a change touches more than one primitive. ```bash theme={null} # Push everything and snapshot in one step lua version create --auto-push -m "checkout flow v2" # Promote the resulting version — e.g. v2 lua version promote 2 ``` See the [Version Command](/cli/version-command) page for the full command set, including `lua version status` (shows what's pushed but not yet live), `lua version diff`, and `lua version list`. ### `lua deploy` - Legacy Single-Primitive Activation * Makes a previously pushed version of **one primitive** live/active. * Still fully supported, and still the fastest path for a one-off, single-primitive fix. * **Behavior depends on the agent's history:** * If the agent has **promoted at least one agent version already**, `lua deploy` performs a **scoped promote**: it creates and promotes a new agent version identical to the current one except for the primitive you just deployed. The change is live immediately, and it still appears in `lua version list` (tagged with a `deploy …` message) — so agent-version history stays consistent even when you use the legacy command. * If the agent has **never created or promoted an agent version**, `lua deploy` activates the primitive directly, as before. ```bash theme={null} # Deploy specific version lua deploy skill --name mySkill --set-version 1.0.5 --force # Deploy latest version lua deploy skill --name mySkill --set-version latest --force ``` ### Key Difference | Command | What it does | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `lua push` | Upload a new version of one primitive to the server (staged, not live) | | `lua version create` + `lua version promote` | Snapshot every primitive, then atomically switch the whole agent to that snapshot — recommended flow | | `lua deploy` | Activate one primitive's version now — scoped-promotes into version history if the agent already has versions, otherwise activates it directly | After `lua push` **without** `--auto-deploy`, code is **staged, not live**. The CLI says so explicitly — for example: ``` ✨ Staged but NOT live yet. To make it active: lua deploy skill ``` Use `lua push … --auto-deploy`, run `lua deploy` explicitly, or run `lua version create` + `lua version promote` before validating in production. **Execution guarantee:** once `lua deploy` or `lua version promote` reports success, that exact code serves the very next invocation of the agent (allow up to roughly a minute for edge caches to catch up globally). Sandbox testing (`lua chat`, `lua test`) never touches what's live, and deactivating a primitive (e.g. a webhook) stops it serving immediately regardless of version history. *** ## 8. Environment Variables ### Sandbox vs Production | Environment | Source | | ----------- | ----------------------------------------------- | | Sandbox | Local `.env` file or CLI env variables | | Production | Stored on server (set via `lua env production`) | ### Commands ```bash theme={null} # List variables lua env sandbox --list lua env production --list # Set variable lua env sandbox -k API_KEY -v "sk-test-xxx" lua env production -k API_KEY -v "sk-live-xxx" # Delete variable lua env production -k OLD_KEY --delete ``` Set environment variables **as needed** when code requires them. *** ## 9. Sandbox Overrides When using `lua chat -e sandbox`, the following can be overridden with local compiled code **without needing to push/deploy**: | Component | How it works | | -------------- | --------------------------------------------------- | | Skills | Local compiled code pushed to Redis cache | | Persona | Local persona overrides deployed production persona | | PreProcessors | Local code pushed to Redis cache | | PostProcessors | Local code pushed to Redis cache | | Environment | Local `.env` file used | This allows testing changes instantly without the push/deploy cycle. *** ## 10. Compilation Gotchas The CLI uses **esbuild** to bundle each `execute` function: ### What Works * Standard TypeScript code * Imports from `package.json` dependencies * Relative imports within your project ### What May Not Work * Not all Node.js file structures are supported * Each execute function's code is extracted and bundled separately * `lua-cli` imports are stripped - APIs like `User`, `Products`, `Data`, `Agents` are sandbox globals ### Warning Signs | Issue | Meaning | | ---------------------------- | ------------------------------------ | | Empty bundles (\< 100 bytes) | Something went wrong during bundling | | "Could not resolve" errors | Check imports/dependencies | | "Transform failed" | TypeScript syntax issues | ### Debug Mode Run with `--debug` for verbose compilation output: ```bash theme={null} lua compile --debug ``` *** ## 11. Platform APIs Available at Runtime These are available as **globals** in the VM sandbox (don't import from lua-cli): | API | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `User` | **Per-user persistent storage** — store onboarding state, preferences, workflow progress, or any custom data that persists across conversations | | `Data` | Custom data collections | | `Products` | Product catalog | | `Baskets` | Shopping cart | | `Orders` | Order management | | `AI` | AI generation | | `Lua` | Request context (`Lua.request.channel`, `Lua.request.webhook`) | | `Jobs` | Job scheduling | | `Templates` | Message templates | | `CDN` | File upload/retrieval | | `BasketStatus` | Enum for basket statuses (`ACTIVE`, `CHECKED_OUT`, `ABANDONED`, `EXPIRED`) | | `OrderStatus` | Enum for order statuses (`PENDING`, `CONFIRMED`, `FULFILLED`, `CANCELLED`) | | `env(key)` | Environment variable access | | `process.env` | Environment variables as an object (alternative to `env()`) | | `fetch` | HTTP requests | | `console` | Logging (appears in `lua logs`) | ### User: Per-User Persistent Storage **Critical for AI agents building stateful workflows.** The `User` API is the primary way to maintain state across conversations. It is a **schemaless, persistent key-value store** scoped to each end-user — not just a profile reader. Any property you set on the user object **persists forever** (across conversations, sessions, days, months) until you explicitly change it. This makes it the go-to primitive for: * **Onboarding flows** — track `user.onboardingStep`, `user.completedSteps` * **Multi-step workflows** — accumulate data across tool calls with `user.collectedData` * **Session state** — store `user.lastIntent`, `user.pendingAction` * **User preferences** — persist `user.theme`, `user.language`, `user.notificationSettings` ```typescript theme={null} // In any tool — read and write arbitrary user state const user = await User.get(); // User is a sandbox global // These properties persist FOREVER until you change them user.onboardingStep = 'identity_verification'; user.collectedData = { name: 'Jane', company: 'Acme' }; user.completedSteps = ['welcome', 'personal_info']; await user.save(); // Next conversation, next day, next month — it's still there const user = await User.get(); console.log(user.onboardingStep); // 'identity_verification' console.log(user.completedSteps); // ['welcome', 'personal_info'] ``` **State machine pattern** — use `user.onboardingStep` (or any field) to track where the user is in a multi-step flow, then resume from that point in any future conversation: ```typescript theme={null} const user = await User.get(); const step = user.onboardingStep || 'not_started'; if (step === 'not_started') { user.onboardingStep = 'collecting_info'; await user.save(); return { message: "Let's get started!" }; } else if (step === 'collecting_info') { user.companyName = input.companyName; user.onboardingStep = 'awaiting_verification'; await user.save(); return { message: 'Now let\'s verify your identity.' }; } // ... continue for each step ``` See the full [User API reference](/api/user) for all methods (`update()`, `save()`, `send()`, `clear()`). *** ## 12. Third-Party Integrations (POWERFUL FEATURE) **This is one of the most powerful features of the Lua platform.** With a single command, you can give your agent access to 250+ third-party services (Linear, Discord, Google Calendar, HubSpot, Slack, GitHub, and more) - **without writing any code**. ### Why This Matters Instead of: * Writing API integration code * Managing OAuth tokens and refresh logic * Building tools for each third-party service * Handling rate limits and error handling You simply run: ```bash theme={null} lua integrations connect --integration linear --auth-method oauth --scopes all --triggers task_task.created ``` And your agent **instantly** gets: * Tools like `linear_create_issue`, `linear_list_projects`, `linear_update_task`, etc. * **Triggers** that wake up your agent when events occur (e.g., when a new issue is created) ### How It Works 1. **Connect** - Authenticate with the third-party service via OAuth or API token 2. **Auto-MCP** - An MCP (Model Context Protocol) server is automatically created 3. **Instant Tools** - Your agent can immediately use tools from that integration 4. **Triggers** - Optionally enable triggers to wake up your agent on events ### Discovery Commands (For AI Agents) Before connecting, use discovery commands to understand what's available: ```bash theme={null} # List all available integrations lua integrations available # Get detailed info about an integration (scopes and triggers) lua integrations info linear # Get info as JSON (for parsing) lua integrations info linear --json # List available trigger events lua integrations webhooks events --integration linear lua integrations webhooks events --integration linear --json ``` **JSON output** is especially useful for AI coding assistants to programmatically discover: * Available OAuth scopes with friendly descriptions * Available trigger events with friendly descriptions * Webhook types (native vs virtual) ### Connecting an Integration ```bash theme={null} # View available integrations lua integrations available # Connect with OAuth and triggers (recommended) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Connect with all available triggers lua integrations connect --integration linear --auth-method oauth --scopes all --triggers all # Connect with specific scopes only (no triggers) lua integrations connect --integration linear --auth-method oauth --scopes "task_task_read,task_task_write" # Connect with API token lua integrations connect --integration linear --auth-method token # List connected integrations lua integrations list # Update scopes on existing connection lua integrations update --integration linear --scopes all # Disconnect lua integrations disconnect --connection-id ``` ### Triggers: Event-Driven Agent Wake-Up **Triggers** are one of the easiest ways to make your agent reactive. When enabled, your agent automatically wakes up when events occur in connected services. ```bash theme={null} # Enable triggers during connection lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Or add triggers after connection lua integrations webhooks create --connection --object task_task --event created # List active triggers lua integrations webhooks list # Delete a trigger lua integrations webhooks delete --webhook-id ``` **What happens when a trigger fires:** 1. An event occurs in the connected service (e.g., a new Linear issue is created) 2. Unified.to sends a webhook to your agent 3. Your agent wakes up with the event data in `runtimeContext` 4. The agent can respond based on what happened ### Testing Integration Tools After connecting, test immediately. Use `--thread` to keep each test isolated: ```bash theme={null} # Test integration tools in isolated threads (can run concurrently) lua chat -e sandbox -m "Create a Linear issue titled 'Test from Lua' in my default project" -t test-linear --clear lua chat -e sandbox -m "List my upcoming Google Calendar events" -t test-gcal --clear lua chat -e sandbox -m "Send a message to the #general channel on Discord" -t test-discord --clear ``` ### Available Integrations (250+) Common integrations include: | Category | Examples | | ------------------- | ------------------------------------------------ | | **Task Management** | Linear, Asana, Jira, Monday.com, ClickUp, Notion | | **Communication** | Discord, Slack, Microsoft Teams, Telegram | | **Calendar** | Google Calendar, Outlook Calendar, Calendly | | **CRM** | HubSpot, Salesforce, Pipedrive, Zoho CRM | | **Development** | GitHub, GitLab, Bitbucket | | **Storage** | Google Drive, Dropbox, OneDrive, Box | | **Support** | Zendesk, Intercom, Freshdesk | | **Email** | Gmail, Outlook, SendGrid | Run `lua integrations available` to see all available integrations for your workspace. ### Managing MCP Servers for Integrations Each integration creates an MCP server. You can manage them: ```bash theme={null} # List MCP servers and their status lua integrations mcp list # Deactivate (hide tools from agent) lua integrations mcp deactivate --connection # Reactivate lua integrations mcp activate --connection ``` ### Integration Workflow Example ```bash theme={null} # 1. Discover available integrations and their capabilities lua integrations available lua integrations info linear --json # 2. Connect Linear with triggers lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # (Complete OAuth in browser) # 3. Verify connection and triggers lua integrations list lua integrations webhooks list # 4. Test the integration in isolated threads lua chat -e sandbox -m "List all Linear projects I have access to" -t test-list --clear lua chat -e sandbox -m "Create a task in Linear: Review documentation updates" -t test-create --clear # 5. The agent now has full Linear capabilities and reacts to events! ``` ### Key Points for AI Agents **Important for AI coding assistants building Lua agents:** 1. **Use discovery commands** to understand what's available: * `lua integrations available` - list integrations * `lua integrations info --json` - get scopes and triggers * `lua integrations webhooks events --integration --json` - get trigger events 2. **Enable triggers** during connection for event-driven workflows: * `--triggers task_task.created,task_task.updated` - specific triggers * `--triggers all` - all available triggers 3. **Connect integrations** before writing custom tool code - the integration might already provide what's needed 4. **Test with `lua chat`** after connecting to verify tools are available 5. **One connection per integration type** per agent - use `update` to change scopes *** ## 13. Complete Workflow Example ```bash theme={null} # 0. Authenticate (one-time setup) # The user runs this in a private terminal outside the AI conversation. lua auth configure # 1. Initialize project (one-time per agent) lua init --agent-id agent_abc123 # 2. Set environment variables (as needed) lua env sandbox -k OPENAI_KEY -v "sk-test-xxx" # 3. (OPTIONAL BUT POWERFUL) Connect third-party integrations lua integrations available # See what's available lua integrations connect --integration linear --auth-method oauth --scopes all lua integrations connect --integration discord --auth-method oauth --scopes all lua integrations list # Verify connections # 4. Write your code in src/ # 5. Test individual components lua test skill --name get_order --input '{"orderId": "123"}' # 6. Check logs if issues lua logs --type skill --name get_order --limit 5 # 7. Test with full agent in isolated threads (sandbox) lua chat -e sandbox -m "Get order 123" -t test-get-order --clear lua chat -e sandbox -m "Create a Linear issue for order 123 review" -t test-linear --clear # If Linear connected # Or run both concurrently lua chat -e sandbox -m "Get order 123" -t test-get-order --clear & lua chat -e sandbox -m "Create a Linear issue" -t test-linear --clear & wait # 8. Push to server lua push skill --name order-service --set-version 1.0.0 --force # 9. Deploy to production # (single primitive, fast path — see §7 for when to prefer # `lua version create --auto-push` + `lua version promote` instead) lua deploy skill --name order-service --set-version latest --force # 10. Set production env vars lua env production -k OPENAI_KEY -v "sk-live-xxx" # 11. Test production in isolated thread lua chat -e production -m "Get order 123" -t prod-verify --clear # 12. Monitor logs lua logs --type skill --name order-service --limit 10 --json ``` *** ## Quick Reference: Non-Interactive Commands | Task | Command | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | New local login | User runs `lua auth configure` in a private terminal | | CI authentication | Set `LUA_API_KEY` from the CI secret manager | | Initialize with existing agent | `lua init --agent-id ` | | Initialize with new agent | `lua init --agent-name --org-id ` | | Test a tool | `lua test skill --name --input ''` | | Test a webhook | `lua test webhook --name --input ''` | | Test a job | `lua test job --name ` | | Chat in sandbox | `lua chat -e sandbox -m ""` | | Chat in production | `lua chat -e production -m ""` | | Chat in isolated thread | `lua chat -e sandbox -m "" -t ` | | Chat in auto-generated thread | `lua chat -e sandbox -m "" -t` | | Isolated test with auto-cleanup | `lua chat -e sandbox -m "" -t --clear` | | Clear all conversation history | `lua chat clear --force` | | Clear another user's history (`org:manage`) | `lua chat clear --user --force` | | Clear specific thread history | `lua chat clear --thread --force` | | View available integrations | `lua integrations available` | | Get integration info (JSON) | `lua integrations info --json` | | List trigger events (JSON) | `lua integrations webhooks events --integration --json` | | Connect with triggers | `lua integrations connect --integration --auth-method oauth --scopes all --triggers ` | | Connect integration (OAuth) | `lua integrations connect --integration --auth-method oauth --scopes all` | | Connect integration (Token) | `lua integrations connect --integration --auth-method token` | | List connected integrations | `lua integrations list` | | Update integration scopes | `lua integrations update --integration --scopes all` | | Disconnect integration | `lua integrations disconnect --connection-id ` | | List triggers | `lua integrations webhooks list` | | Create trigger | `lua integrations webhooks create --connection --object --event ` | | Delete trigger | `lua integrations webhooks delete --webhook-id ` | | List integration MCP status | `lua integrations mcp list` | | Push all | `lua push all --force` | | Push and deploy | `lua push all --force --auto-deploy` | | Deploy specific version (legacy, single primitive) | `lua deploy skill --name --set-version --force` | | Snapshot a release candidate | `lua version create --auto-push -m ""` | | Promote a release atomically | `lua version promote ` | | Check what's pushed but not live | `lua version status` | | Set env variable | `lua env -k -v ` | | View logs | `lua logs --type --name --limit ` | *** ## Related Documentation Canonical post-deploy debug loop and the active `agent_error` probe Complete CLI command documentation All non-interactive flags and CI/CD examples Connect 250+ third-party services Complete LuaAgent configuration reference User, Data, Products, and other runtime APIs Manage MCP servers for external tools Atomic agent versions, promote, rollback, and version status # Agents API Source: https://docs.heylua.ai/api/agents Invoke the current agent or another agent from Lua runtime code ## Overview The Agents API lets Lua runtime code — tools, webhooks, jobs, preprocessors, and postprocessors — invoke the current agent or another agent through the full chat pipeline. The invocation goes through billing, message persistence, skills and tools, preprocessors, postprocessors, and governance on the target agent. ```typescript theme={null} import { Agents } from 'lua-cli'; // Simplified: returns plain text const reply = await Agents.invoke('sales-agent', 'Summarize the last order.'); // Full options: returns structured output const result = await Agents.invoke('sales-agent', { prompt: 'Draft a reply to the latest order', threadId: 'order-123', systemPrompt: 'Be concise.', }); console.log(result.text, result.usage); ``` **Full Pipeline Execution:** Unlike `AI.generate`, `Agents.invoke` routes through the target agent's complete processing stack — including its skills, tools, preprocessors, postprocessors, and governance rules. **Use this inside agent primitives.** `/chat/generate` and `/chat/stream` are external consumption APIs for apps and services. `Agents.invoke` is the runtime API for starting an agent turn from Lua code, so it does not require an API base URL or user bearer token. ## Run Your Own Agent on a Schedule Self-invocation is supported: pass the current agent's ID as `targetAgentId`. This is useful when a `LuaJob` needs the agent itself to run a scheduled prompt with its normal skills and tools. ```typescript theme={null} import { Agents, LuaJob, env } from 'lua-cli'; export default new LuaJob({ name: 'weekly-pipeline-review', description: 'Run the agent weekly against the latest pipeline data', schedule: { type: 'cron', expression: '0 7 * * 1', timezone: 'UTC' }, execute: async (job) => { const currentAgentId = env('CURRENT_AGENT_ID'); if (!currentAgentId) throw new Error('CURRENT_AGENT_ID is required'); return Agents.invoke(currentAgentId, { prompt: 'Review the current pipeline and produce the weekly digest.', userId: job.metadata.userId, threadId: 'weekly-pipeline-review', }); }, metadata: { userId: 'user_abc123' }, }); ``` `Agents.invoke` currently requires an explicit `targetAgentId`; there is no `self` sentinel or ambient current-agent ID in the runtime API. A reusable template that self-invokes must still receive its deployed agent ID as configuration. It does **not** need an API URL or bearer token. ## Import ```typescript theme={null} import { Agents } from 'lua-cli'; // or import { Agents } from 'lua-cli/skill'; ``` ## Calling Contexts | Context | User identity | Recommended pattern | | -------------------------------- | -------------------------------- | ------------------------------------------------------------------------ | | **Tool** (user turn) | Caller's user automatically used | `Agents.invoke(id, prompt)` — no `userId` needed | | **Dynamic Job** (Jobs API) | Caller's user automatically used | `Agents.invoke(id, prompt)` — no `userId` needed | | **Webhook** | No ambient user | Pass `userId` from event payload, or omit to run without a user identity | | **Pre-defined LuaJob** | No ambient user | Pass `userId` from metadata, or omit to run without a user identity | | **PreProcessor / PostProcessor** | Caller's user automatically used | `Agents.invoke(id, prompt)` — no `userId` needed | **No user identity** — when no `userId` is available (e.g. a webhook with no user context), the invocation runs without a user identity and no conversation history is stored. Use `userId` from your event payload whenever you have one. ### Execution and delivery semantics `Agents.invoke` waits for a complete agent turn and returns that turn's response to the calling code. During the turn, the target agent's tools execute normally and their side effects are real. The returned response is **not automatically sent to a user or channel**. Delivery only occurs if the invoked agent calls a delivery tool, or if the calling code sends `result.text` through a runtime API such as `User` or `Channels`. * In a user-authenticated tool, dynamic job, or processor, the ambient user is used automatically. * In a pre-defined `LuaJob` or userless webhook, pass `userId` when the invoked tools or later delivery need a user. Without it, the turn runs in system scope with no user profile or conversation history. * `channel` sets the target turn's channel context. It does not, by itself, deliver the returned text to that channel. ## Methods ### Simplified: `Agents.invoke(targetAgentId, prompt)` Invoke an agent with a plain text prompt. Returns the assistant's response as a plain string. The target agent's identifier (e.g. `'sales-agent'`, `'support-agent'`). Pass the current agent's own ID to self-invoke. There is currently no `self` sentinel or ambient self-ID. Plain-text message to send to the target agent. **Returns:** `Promise` ```typescript theme={null} const summary = await Agents.invoke('sales-agent', 'Summarize the last order.'); console.log(summary); // "The last order was for 3 units of..." ``` ### Full options: `Agents.invoke(targetAgentId, input)` Invoke an agent with full control over the request. Returns a structured output object. The target agent's identifier. Pass the current agent's own ID to self-invoke. Full invocation options — see below. **Returns:** `Promise` ## `AgentInvocationInput` Plain-text user message. Mutually exclusive with `messages`. AI SDK `UserContent` (array of `TextPart`, `ImagePart`, `FilePart`). Mutually exclusive with `prompt`. Override the target agent's system prompt for this invocation only. Additional runtime context string attached to the request (e.g. serialised metadata). Client-side context for the invocation. Supports `timezone` — an IANA timezone string (e.g. `'Africa/Nairobi'`) used as the user's local timezone for date/time-aware responses. When omitted, the target agent falls back to the user's stored profile, country, or UTC. Thread ID suffix for conversation scoping. When omitted, the invocation flows into the caller-user's default chat thread with the target agent — the same behaviour as a direct message. Pass a custom value to isolate this invocation in its own thread. Channel context for the invoked turn. Defaults to `'agent-invocation'`. This does not automatically deliver the returned response to a channel. Free-form request tag persisted on the stored message record (e.g. a UUID or external trace ID for correlation). Not a user identifier. Explicit user to invoke as. Use from context-less triggers — webhooks and pre-defined jobs — where there is no ambient user. When omitted from a webhook or pre-defined job, the invocation runs without user identity and no conversation history is stored. This field is **ignored** during a user-authenticated turn; the turn's user is always used. ## `AgentInvocationOutput` | Field | Type | Description | | -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `text` | `string` | Final response text (after the target agent's postprocessors). | | `finishReason` | `string?` | AI SDK `FinishReason` — `'stop'`, `'length'`, `'content-filter'`, `'preprocessor_blocked'`, `'governance_blocked'`, etc. | | `usage` | `object?` | Token usage: `{ inputTokens, outputTokens, totalTokens, reasoningTokens, cachedInputTokens }` | | `toolsUsed` | `string[]?` | Names of tools invoked by the target agent during generation. | | `threadId` | `string?` | Echoes back the `threadId` the caller passed in, if any. | ## Error Handling `Agents.invoke` throws an `Error` for any non-success response. Wrap calls in `try/catch` and inspect the message: ```typescript theme={null} import { Agents } from 'lua-cli'; async function delegateToAgent(agentId: string, prompt: string) { try { const result = await Agents.invoke(agentId, { prompt }); if (result.finishReason === 'preprocessor_blocked') { return { blocked: true, message: result.text }; } if (result.finishReason === 'governance_blocked') { return { blocked: true, message: result.text }; } return { success: true, text: result.text }; } catch (error) { // Network, timeout, target disabled, insufficient credits, etc. return { success: false, error: error.message }; } } ``` **Common error causes:** | Cause | Description | | --------------------- | ---------------------------------------------------------------------- | | Target agent disabled | The target agent is not available to the invoking user. | | Insufficient credits | The account does not have enough credits to run the invocation. | | Timeout | The target agent took longer than 120 s to respond. | | Service unreachable | The platform could not be reached (may occur during local `lua test`). | ## Complete Examples ### Tool routing to a specialist agent ```typescript theme={null} import { LuaTool, Agents } from 'lua-cli/skill'; import { z } from 'zod'; export default class EscalateToLegalTool implements LuaTool { name = 'escalate_to_legal'; description = 'Send a legal query to the dedicated legal-review agent'; inputSchema = z.object({ query: z.string().describe('The legal question to review'), contractId: z.string().optional(), }); async execute(input: z.infer) { const result = await Agents.invoke('legal-review-agent', { prompt: input.query, threadId: input.contractId ? `contract-${input.contractId}` : undefined, systemPrompt: 'Respond in plain language suitable for a non-lawyer.', }); return { legalAdvice: result.text, tokensUsed: result.usage?.totalTokens, }; } } ``` ### Webhook delegating to an agent When a webhook fires you typically have a user ID in the event payload — pass it via `userId` so the invocation runs in that user's context: ```typescript theme={null} import { LuaWebhook, Agents } from 'lua-cli'; const orderWebhook = new LuaWebhook({ name: 'order-shipped-webhook', description: 'Notify users when their order ships', execute: async (event) => { const { orderId, customerId, trackingNumber } = event.body ?? {}; if (!customerId) { return { skipped: true, reason: 'no customerId in payload' }; } // Pass userId so the invocation runs as that user — conversation history // is stored and any per-user agent rules apply. await Agents.invoke('notification-agent', { prompt: `Order ${orderId} has shipped. Tracking: ${trackingNumber}. Notify the customer.`, userId: customerId, }); return { notified: true, customerId }; }, }); export default orderWebhook; ``` ### Pre-defined job with no user context When no `userId` is available, omit it and the invocation runs without user identity. No conversation history is stored. ```typescript theme={null} import { LuaJob, Agents } from 'lua-cli'; const dailyDigest = new LuaJob({ name: 'daily-digest-generator', schedule: { type: 'cron', expression: '0 6 * * *' }, execute: async (job) => { // No userId available — invocation runs without a user context const result = await Agents.invoke('digest-agent', { prompt: 'Generate the daily product digest for today.', }); // Store the result for other processes to pick up return { digest: result.text, generatedAt: new Date().toISOString() }; }, }); export default dailyDigest; ``` If the invoked agent uses `User.get()`, `User.send()`, or another user-scoped tool, include a real `userId` in the invocation. A system-scoped invocation cannot infer the template installer or a delivery recipient. ### Multi-modal input (image analysis) ```typescript theme={null} import { LuaTool, Agents } from 'lua-cli/skill'; import { z } from 'zod'; export default class AnalyseReceiptTool implements LuaTool { name = 'analyse_receipt'; description = 'Send a receipt image to the expense-processing agent'; inputSchema = z.object({ imageUrl: z.string().url(), }); async execute(input: z.infer) { const result = await Agents.invoke('expense-agent', { messages: [ { type: 'text', text: 'Extract line items and total from this receipt.' }, { type: 'image', url: input.imageUrl }, ], }); return { extraction: result.text }; } } ``` ## Limitations * **No recursion guard** — avoid infinite agent-invoke loops in your code. * **Explicit target required** — self-invocation works, but you must pass the current agent's ID. There is no `self` sentinel or ambient self-ID yet. * **No native fire-and-forget** — `Agents.invoke` is always awaited. To run in the background, use the Jobs API to schedule a dynamic job. * **`skillOverride` / `preprocessorOverride` not exposed** — the target agent always runs with its configured skills and preprocessors. * **Persona override not exposed** — use `systemPrompt` to influence behaviour without replacing the full persona. * **120 s timeout** — invocations that take longer than 120 seconds will throw a timeout error. ## Related APIs Generate text outside the agent pipeline Get or update user context HTTP endpoints for external events Pre-defined scheduled tasks Dynamic job creation Format agent responses ## See Also * [HTTP API](/channels/http-api) — external `/chat/generate` and `/chat/stream` endpoints for apps and services consuming an agent * [AI API](/api/ai) — isolated text generation without the full agent pipeline * [LuaWebhook](/api/luawebhook) — triggering agents from external events * [LuaJob](/api/luajob) — scheduled agent invocations # AI API Source: https://docs.heylua.ai/api/ai Generate AI responses with custom context in your tools ## Overview The AI API provides isolated text generation from within tools, aligned with [Vercel AI SDK `generateText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) semantics. Requests are proxied to a dedicated generation endpoint — they do **not** go through the agent chat pipeline. ```typescript theme={null} import { AI } from 'lua-cli'; // Quick text generation const text = await AI.generate('Summarize the latest AI news.'); // Full options with rich result const result = await AI.generate({ model: 'google/gemini-2.0-flash', system: 'You are concise.', prompt: 'What is the weather in London?', }); ``` **Powerful Tool Enhancement:** The AI API lets you embed AI generation directly in your tools with custom personas, multi-modal inputs, and rich response metadata. ## Import ```typescript theme={null} import { AI } from 'lua-cli'; // or import { AI } from 'lua-cli/skill'; ``` ## Capabilities Generate content with custom prompts and personas Analyze images with AI vision capabilities Process and analyze documents with AI Combine text, images, and files in one request Google (Vertex AI), OpenAI, and Anthropic models Google Search grounding with source URLs ## Methods ### Simplified: AI.generate(prompt, content?) Quick text generation. Returns plain text as a string. When called with one argument, this is the user prompt. When called with two arguments, it becomes the system instruction. User message content. Accepts a string or an array of multimodal parts (`TextPart`, `ImagePart`, `FilePart`) from the AI SDK. **Returns:** `Promise` ```typescript theme={null} // Single argument: prompt is the user message const text = await AI.generate('Summarize the latest AI news.'); // Two arguments: system instruction + user content const text2 = await AI.generate( 'You are a helpful assistant.', [{ type: 'text', text: 'What products do you recommend?' }] ); // Multi-modal content const analysis = await AI.generate( 'You are an image analysis expert.', [ { type: 'text', text: 'What do you see?' }, { type: 'image', url: 'https://example.com/photo.jpg' } ] ); ``` ### Full options: AI.generate(options) Full control over generation parameters. Returns a rich result object. Model to use, e.g. `'google/gemini-2.0-flash'`, `'openai/gpt-4o'`, `'anthropic/claude-sonnet-4-20250514'`. Defaults to the agent's configured model. System instruction. User prompt (simple text). Conversation messages (AI SDK `ModelMessage[]`). Sampling temperature (0–2). Maximum tokens to generate. Constrain the model response to a JSON Schema. The parsed object lands on `result.output`. Mirrors AI SDK `Output.object({ schema })`. Object-mode only — array / choice modes are future extensions. When set on a Google model, the auto-injected `google_search` tool is suppressed (Vertex does not allow mixing function-calling tools with `google_search` in one request). **Returns:** `Promise` ```typescript theme={null} const result = await AI.generate({ model: 'google/gemini-2.0-flash', system: 'You are concise.', prompt: 'What is the weather in London?', temperature: 0.7, }); result.text // Generated text result.finishReason // 'stop', 'length', etc. result.usage // { promptTokens, completionTokens, totalTokens } result.sources // Google Search grounding URLs ``` ### Response Shape (AiGenerateOutput) The full-options response mirrors AI SDK `GenerateTextResult`: | Field | Type | Description | | ---------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | | `text` | `string` | Generated text | | `finishReason` | `FinishReason` | `'stop'`, `'length'`, `'content-filter'`, `'tool-calls'`, `'error'`, `'other'`, `'unknown'` | | `usage` | `LanguageModelUsage` | `{ promptTokens, completionTokens, totalTokens }` | | `reasoning?` | `ReasoningOutput[]` | Model reasoning steps (e.g. Gemini thinking) | | `reasoningText?` | `string` | Concatenated reasoning text | | `sources?` | `AiGenerateSource[]` | URL sources from Google Search grounding | | `toolCalls?` | `AiGenerateToolCall[]` | Tool calls made during generation | | `toolResults?` | `AiGenerateToolResult[]` | Tool results from generation | | `output?` | `unknown` | Parsed structured result when `structuredOutput` was set on the request. Shape conforms to the supplied JSON Schema. | | `warnings?` | `CallWarning[]` | Provider warnings | ## Supported Providers | Provider | Prefix | Example | | ------------------ | ------------ | ------------------------------------ | | Google (Vertex AI) | `google/` | `google/gemini-2.0-flash` | | OpenAI | `openai/` | `openai/gpt-4o` | | Anthropic | `anthropic/` | `anthropic/claude-sonnet-4-20250514` | If the requested provider's API key is not configured, the request falls back to the default Vertex AI model. Google models automatically get **Google Search grounding** — real-time web search results appear in the `sources` field of the full-options response. ## Content Types ### Text ```typescript theme={null} [{ type: 'text', text: 'Your message here' }] ``` ### Image ```typescript theme={null} [ { type: 'text', text: 'What do you see in this image?' }, { type: 'image', url: 'https://example.com/photo.jpg' } ] ``` ### File ```typescript theme={null} [ { type: 'text', text: 'Summarize this document' }, { type: 'file', url: 'https://example.com/doc.pdf', mimeType: 'application/pdf' } ] ``` ## Complete Examples ### Product Description Generator ```typescript theme={null} import { LuaTool, AI, Products } from 'lua-cli/skill'; import { z } from 'zod'; export default class GenerateDescriptionTool implements LuaTool { name = 'generate_product_description'; description = 'Generate compelling product descriptions using AI'; inputSchema = z.object({ productId: z.string(), style: z.enum(['casual', 'professional', 'luxury']).optional() }); async execute(input: z.infer) { const product = await Products.getById(input.productId); if (!product) { return { success: false, error: 'Product not found' }; } const style = input.style || 'professional'; const description = await AI.generate( `You are a ${style} copywriter. Create a 2-3 sentence product description.`, [{ type: 'text', text: `Product: ${product.name}, Price: $${product.price}` }] ); await product.update({ description }); return { success: true, productId: input.productId, description }; } } ``` ### Weather Search with Google Grounding ```typescript theme={null} import { LuaTool, AI } from 'lua-cli/skill'; import { z } from 'zod'; export default class WeatherSearchTool implements LuaTool { name = 'search_weather'; description = 'Search current weather using AI with Google Search grounding'; inputSchema = z.object({ location: z.string().describe('City or location') }); async execute(input: z.infer) { const result = await AI.generate({ model: 'google/gemini-2.0-flash', system: 'You report current weather conditions concisely.', prompt: `What is the current weather in ${input.location}?`, }); return { weather: result.text, sources: result.sources?.map(s => ({ title: s.title, url: s.url })) ?? [], usage: result.usage, }; } } ``` ### Image Analysis Tool ```typescript theme={null} import { LuaTool, AI } from 'lua-cli/skill'; import { z } from 'zod'; export default class AnalyzeImageTool implements LuaTool { name = 'analyze_image'; description = 'Analyze images using AI vision'; inputSchema = z.object({ imageUrl: z.string().url(), question: z.string().optional() }); async execute(input: z.infer) { const analysis = await AI.generate( 'You are an image analysis expert. Describe what you see in detail.', [ { type: 'text', text: input.question || 'Describe this image in detail' }, { type: 'image', url: input.imageUrl } ] ); return { success: true, imageUrl: input.imageUrl, analysis }; } } ``` ### Content Summarizer ```typescript theme={null} import { LuaTool, AI } from 'lua-cli/skill'; import { z } from 'zod'; export default class SummarizeTool implements LuaTool { name = 'summarize_content'; description = 'Summarize long text content'; inputSchema = z.object({ content: z.string(), maxLength: z.enum(['brief', 'medium', 'detailed']).optional() }); async execute(input: z.infer) { const instructions = { brief: 'in 1-2 sentences', medium: 'in 1 paragraph', detailed: 'in 2-3 paragraphs with key points' }; const length = input.maxLength || 'medium'; const summary = await AI.generate( `You are a professional content summarizer. Create a clear summary ${instructions[length]}.`, [{ type: 'text', text: `Summarize:\n\n${input.content}` }] ); return { success: true, summary, originalLength: input.content.length }; } } ``` ### Translation Tool ```typescript theme={null} import { LuaTool, AI } from 'lua-cli/skill'; import { z } from 'zod'; export default class TranslateTool implements LuaTool { name = 'translate_text'; description = 'Translate text to different languages'; inputSchema = z.object({ text: z.string(), targetLanguage: z.string(), sourceLanguage: z.string().optional() }); async execute(input: z.infer) { const translation = await AI.generate( `You are a professional translator. Translate accurately to ${input.targetLanguage}. Return ONLY the translated text.`, [{ type: 'text', text: input.text }] ); return { success: true, original: input.text, translation, from: input.sourceLanguage || 'auto-detect', to: input.targetLanguage }; } } ``` ### Sentiment Analysis (Structured Output) Use `structuredOutput` to make the model return an object that conforms to a JSON Schema. The parsed result lands on `result.output` — no more `JSON.parse(...)` and no more "the model wrapped it in markdown again" bugs. ```typescript theme={null} import { LuaTool, AI } from 'lua-cli/skill'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const SentimentSchema = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral']), score: z.number().min(0).max(1), summary: z.string(), }); export default class SentimentAnalysisTool implements LuaTool { name = 'analyze_sentiment'; description = 'Analyze the sentiment of text'; inputSchema = z.object({ text: z.string() }); async execute(input: z.infer) { const result = await AI.generate({ system: 'You are a sentiment analysis expert.', prompt: `Analyze:\n\n"${input.text}"`, temperature: 0, structuredOutput: { schema: zodToJsonSchema(SentimentSchema) as Record, }, }); const parsed = SentimentSchema.safeParse(result.output); if (!parsed.success) { return { success: false, error: 'Schema mismatch', rawResponse: result.text }; } return { success: true, ...parsed.data, originalText: input.text }; } } ``` ## Best Practices Good context leads to better results ```typescript theme={null} // Good — specific and clear const context = `You are a product reviewer. Rate products on Quality (1-10), Value (1-10), Features (1-10). Return ONLY a JSON object with these ratings.`; // Bad — vague const context = `Review this product`; ``` Use simplified for quick text, full options when you need metadata ```typescript theme={null} // Simplified — just need the text const summary = await AI.generate('Summarize this article.', content); // Full options — need usage stats, sources, finish reason const result = await AI.generate({ model: 'google/gemini-2.0-flash', prompt: 'What is happening in tech today?', }); console.log(result.sources); // Google Search grounding URLs ``` AI responses may vary — always validate ```typescript theme={null} try { const response = await AI.generate(context, messages); if (!response || response.trim().length === 0) { return { success: false, error: 'Empty response' }; } return { success: true, response }; } catch (error) { return { success: false, error: error.message }; } ``` Use regular code for deterministic tasks ```typescript theme={null} // Bad — waste of AI for simple math await AI.generate('Calculate 2 + 2'); // Good — use regular code const result = 2 + 2; ``` ## Common Use Cases | Use Case | Example Tool | | ---------------------- | ------------------------------------------------------ | | **Content generation** | Product descriptions, email drafts, social media posts | | **Analysis** | Sentiment analysis, image recognition, document review | | **Translation** | Multi-language support | | **Summarization** | Long documents, articles, conversations | | **Recommendations** | Product suggestions, content curation | | **Moderation** | Content filtering, safety checks | | **Q\&A** | Document queries, knowledge retrieval | | **Web search** | Real-time information via Google Search grounding | ## Performance Considerations AI generation typically takes 1-5 seconds. Longer/more complex requests take more time. Maximum context length varies by model. Keep contexts focused and relevant. Image analysis is slower than text. Optimize image sizes when possible. Cache common AI responses. Store frequently requested generations. ## Limitations * Maximum context length varies by model * Image size limits apply (optimize before sending) * File types supported: PDF, images, text files * Response may vary between calls (non-deterministic) * Some content may be filtered for safety * Processing time increases with input complexity ## Error Handling ```typescript theme={null} async execute(input: any) { try { const response = await AI.generate(context, messages); if (!response || response.trim().length === 0) { return { success: false, error: 'AI returned empty response' }; } return { success: true, response }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'AI generation failed' }; } } ``` ## Related APIs Get user context for personalization Store AI-generated content Enhance products with AI descriptions Schedule AI generation tasks ## See Also * [LuaTool](/api/luatool) - Creating tools * [Tool Examples](/examples/overview) - More tool patterns * [User API](/api/user) - Accessing user data # Baskets API Source: https://docs.heylua.ai/api/baskets Shopping cart management for e-commerce ## Overview The Baskets API provides complete shopping cart functionality with item management, status tracking, and checkout capabilities. Returns **BasketInstance** objects with direct property access. ```typescript theme={null} import { Baskets, BasketStatus } from 'lua-cli'; // Create basket - returns BasketInstance const basket = await Baskets.create({ currency: 'USD' }); // Direct property access console.log(basket.itemCount); // 0 console.log(basket.totalAmount); // 0 console.log(basket.status); // "active" // Add item using instance method await basket.addItem({ id: productId, price: 29.99, quantity: 2 }); // Access updated properties directly console.log(basket.itemCount); // 2 console.log(basket.totalAmount); // 59.98 // Checkout using instance method const order = await basket.placeOrder({ shippingAddress: {...}, paymentMethod: 'stripe' }); ``` Access `basket.itemCount` not `basket.common.itemCount` Built-in methods like `addItem()`, `placeOrder()` Properties update after method calls Full TypeScript support ## Basket Statuses ```typescript theme={null} import { BasketStatus } from 'lua-cli'; ``` Currently being used for shopping Default status for new baskets Converted to an order Set automatically during checkout User left without completing purchase Can be set manually or by TTL TTL (time-to-live) exceeded Automatically set after expiration ## Methods ### create() Create a new shopping basket. ```typescript theme={null} Baskets.create(data: CreateBasketRequest): Promise ``` Currency code (USD, EUR, GBP, etc.) Custom metadata to store with basket **Returns:** `BasketInstance` with direct property access and methods **Example:** ```typescript theme={null} const basket = await Baskets.create({ currency: 'USD', metadata: { source: 'web', campaign: 'summer_sale' } }); // Direct property access console.log(basket.id); // "basket_abc123" console.log(basket.itemCount); // 0 console.log(basket.totalAmount); // 0 console.log(basket.status); // "active" console.log(basket.metadata.campaign); // "summer_sale" // Instance methods available await basket.addItem({...}); await basket.updateMetadata({...}); ``` ### get() Retrieve baskets, optionally filtered by status. ```typescript theme={null} Baskets.get(status?: BasketStatus): Promise ``` **Examples:** ```typescript theme={null} // Get all baskets const allBaskets = await Baskets.get(); // Get only active baskets const activeBaskets = await Baskets.get(BasketStatus.ACTIVE); // Get abandoned baskets const abandoned = await Baskets.get(BasketStatus.ABANDONED); ``` ### getById() Get a specific basket by ID. ```typescript theme={null} Baskets.getById(basketId: string): Promise ``` **Returns:** `BasketInstance` with direct property access **Example:** ```typescript theme={null} const basket = await Baskets.getById('basket_abc123'); // Direct property access (no .common needed!) console.log(basket.totalAmount); // 59.98 console.log(basket.itemCount); // 2 console.log(basket.status); // "active" console.log(basket.items); // Array of items // Instance methods await basket.addItem({...}); await basket.clear(); ``` ### addItem() Add an item to a basket. ```typescript theme={null} Baskets.addItem(basketId: string, item: BasketItem): Promise ``` Or use instance method (recommended — returns enriched instance): ```typescript theme={null} basket.addItem(item: BasketItem): Promise ``` ID of the basket Product ID Item price Quantity to add Stock keeping unit **Returns:** Static method returns raw `Basket` data. Instance method returns `BasketInstance` with updated item count and total. **Example:** ```typescript theme={null} // Using static method const basket = await Baskets.addItem('basket_abc123', { id: 'product_xyz', price: 29.99, quantity: 2, SKU: 'SHIRT-M-BLUE' }); // Or using instance method (recommended) await basket.addItem({ id: 'product_xyz', price: 29.99, quantity: 2 }); // Direct property access console.log(basket.totalAmount); // 59.98 console.log(basket.itemCount); // 2 console.log(basket.items); // Array of items ``` ### removeItem() Remove an item from a basket. ```typescript theme={null} Baskets.removeItem(basketId: string, itemId: string): Promise ``` **Example:** ```typescript theme={null} await Baskets.removeItem('basket_abc123', 'item_xyz'); ``` ### clear() Remove all items from a basket. ```typescript theme={null} Baskets.clear(basketId: string): Promise ``` **Example:** ```typescript theme={null} const emptyBasket = await Baskets.clear('basket_abc123'); console.log(emptyBasket.common.itemCount); // 0 ``` ### updateStatus() Update basket status. ```typescript theme={null} Baskets.updateStatus(basketId: string, status: BasketStatus): Promise ``` **Example:** ```typescript theme={null} await Baskets.updateStatus('basket_abc123', BasketStatus.ABANDONED); ``` ### updateMetadata() Update basket metadata. ```typescript theme={null} Baskets.updateMetadata(basketId: string, metadata: Record): Promise> ``` **Example:** ```typescript theme={null} await Baskets.updateMetadata('basket_abc123', { notes: 'Gift wrapping requested', giftMessage: 'Happy Birthday!', deliveryDate: '2025-12-25' }); ``` ### placeOrder() Convert basket to order (checkout). ```typescript theme={null} Baskets.placeOrder(orderData: Record, basketId: string): Promise ``` Shipping address information Payment method (e.g., 'stripe', 'paypal') Basket ID to convert to order **Example:** ```typescript theme={null} const order = await Baskets.placeOrder({ shippingAddress: { street: '123 Main St', city: 'New York', state: 'NY', zip: '10001', country: 'USA' }, paymentMethod: 'stripe' }, 'basket_abc123'); console.log(order.id); // "order_def456" console.log(order.common.status); // "pending" ``` ## BasketInstance All basket methods return `BasketInstance` objects with: **Direct Property Access:** ```typescript theme={null} basket.id basket.totalAmount // No .common needed! basket.itemCount // Direct access basket.status // Direct access basket.items // Array of items basket.currency basket.metadata basket.createdAt // Via proxy basket.updatedAt // Via proxy ``` **Instance Methods:** ```typescript theme={null} await basket.addItem({...}); await basket.removeItem(itemId); await basket.updateMetadata({...}); await basket.updateStatus(status); await basket.clear(); await basket.placeOrder({...}); ``` **Backward Compatible:** ```typescript theme={null} basket.totalAmount; // ✅ New way basket.common.totalAmount; // ✅ Old way still works ``` ## Complete Shopping Flow Example ```typescript theme={null} import { LuaTool, Products, Baskets } from 'lua-cli'; import { z } from 'zod'; // Tool 1: Create Basket export class CreateBasketTool implements LuaTool { name = "create_basket"; description = "Start a new shopping session"; inputSchema = z.object({}); async execute(input: any) { const basket = await Baskets.create({ currency: 'USD', metadata: { createdBy: 'chat' } }); return { basketId: basket.id, message: "Shopping basket created! Start adding items." }; } } // Tool 2: Add to Basket export class AddToBasketTool implements LuaTool { name = "add_to_basket"; description = "Add a product to shopping basket"; inputSchema = z.object({ basketId: z.string(), productId: z.string(), quantity: z.number().min(1).default(1) }); async execute(input: z.infer) { // Get product details const product = await Products.getById(input.productId); // Check stock if (!product.inStock) { return { success: false, message: `${product.name} is out of stock` }; } // Add to basket const basket = await Baskets.addItem(input.basketId, { id: input.productId, price: product.price, quantity: input.quantity, SKU: product.sku }); return { basketId: basket.id, itemCount: basket.common.itemCount, total: `$${basket.common.totalAmount.toFixed(2)}`, message: `Added ${input.quantity}x ${product.name} to basket` }; } } // Tool 3: Checkout export class CheckoutTool implements LuaTool { name = "checkout"; description = "Complete purchase and create order"; inputSchema = z.object({ basketId: z.string(), shippingAddress: z.object({ street: z.string(), city: z.string(), state: z.string(), zip: z.string() }), paymentMethod: z.string().default('stripe') }); async execute(input: z.infer) { // Get basket const basket = await Baskets.getById(input.basketId); // Verify basket has items if (basket.common.itemCount === 0) { return { success: false, message: "Cannot checkout empty basket" }; } // Place order const order = await Baskets.placeOrder({ shippingAddress: input.shippingAddress, paymentMethod: input.paymentMethod }, input.basketId); return { success: true, orderId: order.id, total: `$${basket.common.totalAmount.toFixed(2)}`, status: order.common.status, message: "Order placed successfully!" }; } } ``` ## Best Practices ```typescript theme={null} const basket = await Baskets.getById(basketId); if (!basket) { throw new Error(`Basket not found: ${basketId}`); } ``` ```typescript theme={null} const basket = await Baskets.getById(basketId); if (basket.common.itemCount === 0) { return { success: false, message: "Cannot checkout empty basket" }; } ``` Use metadata for tracking: ```typescript theme={null} await Baskets.updateMetadata(basketId, { source: 'mobile_app', promotionCode: 'SUMMER2025', referralSource: 'instagram' }); ``` ```typescript theme={null} // Mark as abandoned after timeout setTimeout(async () => { await Baskets.updateStatus( basketId, BasketStatus.ABANDONED ); }, 30 * 60 * 1000); // 30 minutes ``` ## Common Patterns ### View Cart ```typescript theme={null} export class ViewCartTool implements LuaTool { async execute(input: { basketId: string }) { const basket = await Baskets.getById(input.basketId); return { items: basket.items.map(item => ({ product: item.id, quantity: item.quantity, price: `$${item.price}`, subtotal: `$${(item.price * item.quantity).toFixed(2)}` })), total: `$${basket.common.totalAmount.toFixed(2)}`, itemCount: basket.common.itemCount }; } } ``` ### Apply Discount ```typescript theme={null} export class ApplyDiscountTool implements LuaTool { async execute(input: { basketId: string; code: string }) { const basket = await Baskets.getById(input.basketId); // Apply discount logic const discountPercent = getDiscountForCode(input.code); const discountAmount = basket.common.totalAmount * (discountPercent / 100); await Baskets.updateMetadata(input.basketId, { discountCode: input.code, discountAmount, originalTotal: basket.common.totalAmount }); return { discount: `${discountPercent}%`, saved: `$${discountAmount.toFixed(2)}`, newTotal: `$${(basket.common.totalAmount - discountAmount).toFixed(2)}` }; } } ``` ## Next Steps Manage orders after checkout See complete workflow examples # CDN Source: https://docs.heylua.ai/api/cdn Upload and retrieve files from the Lua CDN ## Overview The CDN API allows you to upload and retrieve files from the Lua CDN. Files are stored securely and can be accessed by their unique file ID. ```typescript theme={null} import { CDN } from 'lua-cli'; // Upload a file const fileId = await CDN.upload(file); // Retrieve a file const file = await CDN.get(fileId); ``` ## Methods ### upload Uploads a file to the CDN. ```typescript theme={null} const fileId = await CDN.upload(file); ``` **Parameters:** | Parameter | Type | Description | | --------- | ------ | ------------------------- | | `file` | `File` | The File object to upload | **Returns:** `Promise` - The unique file ID **Example:** ```typescript theme={null} import { CDN } from 'lua-cli'; import { readFileSync } from 'fs'; // From a buffer const buffer = readFileSync('image.png'); const file = new File([buffer], 'image.png', { type: 'image/png' }); const fileId = await CDN.upload(file); console.log('Uploaded:', fileId); ``` ### get Retrieves a file from the CDN by its ID. ```typescript theme={null} const file = await CDN.get(fileId); ``` **Parameters:** | Parameter | Type | Description | | --------- | -------- | --------------------------------- | | `fileId` | `string` | The unique identifier of the file | **Returns:** `Promise` - The File object with `name`, `type`, and `size` properties **Example:** ```typescript theme={null} import { CDN } from 'lua-cli'; const file = await CDN.get('abc123-def456'); console.log(file.name); // filename console.log(file.type); // e.g., 'image/png' console.log(file.size); // bytes ``` ## Use Cases ### Store User Uploads ```typescript theme={null} export class SaveDocumentTool implements LuaTool { name = "save_document"; description = "Save a document to storage"; inputSchema = z.object({ content: z.string(), filename: z.string() }); async execute(input: z.infer) { const file = new File( [input.content], input.filename, { type: 'text/plain' } ); const fileId = await CDN.upload(file); return { success: true, fileId, message: `Document saved as ${input.filename}` }; } } ``` ### Retrieve and Process Files ```typescript theme={null} export class ReadDocumentTool implements LuaTool { name = "read_document"; description = "Read a document from storage"; inputSchema = z.object({ fileId: z.string() }); async execute(input: z.infer) { const file = await CDN.get(input.fileId); const content = await file.text(); return { success: true, filename: file.name, content }; } } ``` ### Use with AI for Image Analysis ```typescript theme={null} export class AnalyzeImageTool implements LuaTool { name = "analyze_image"; description = "Analyze an image using AI"; inputSchema = z.object({ fileId: z.string(), question: z.string() }); async execute(input: z.infer) { const file = await CDN.get(input.fileId); const buffer = Buffer.from(await file.arrayBuffer()); const analysis = await AI.generate( 'You are an image analysis expert.', [ { type: 'text', text: input.question }, { type: 'image', image: buffer, mediaType: file.type } as any ] ); return { analysis }; } } ``` ### Re-upload Files Since `CDN.get()` returns a `File` object, you can easily re-upload files: ```typescript theme={null} // Get existing file const file = await CDN.get(existingFileId); // Re-upload (creates a new copy) const newFileId = await CDN.upload(file); ``` ## File Properties The `File` object returned by `CDN.get()` has these properties: | Property | Type | Description | | -------- | -------- | ----------------------------- | | `name` | `string` | Original filename | | `type` | `string` | MIME type (e.g., `image/png`) | | `size` | `number` | File size in bytes | And these methods: | Method | Returns | Description | | --------------- | ---------------------- | -------------- | | `text()` | `Promise` | Read as text | | `arrayBuffer()` | `Promise` | Read as binary | | `stream()` | `ReadableStream` | Read as stream | ## Supported File Types The CDN supports all file types including: * **Images**: PNG, JPEG, GIF, WebP, SVG * **Documents**: PDF, DOC, DOCX, TXT * **Data**: JSON, CSV, XML * **Audio/Video**: MP3, MP4, WAV * **Archives**: ZIP, TAR Images are automatically optimized with WebP compression while maintaining quality. ## Best Practices Include meaningful names when creating Files: ```typescript theme={null} // Good new File([data], 'invoice-2024-001.pdf', { type: 'application/pdf' }) // Avoid new File([data], 'file.pdf', { type: 'application/pdf' }) ``` Always specify the correct MIME type: ```typescript theme={null} // Images { type: 'image/png' } { type: 'image/jpeg' } // Documents { type: 'application/pdf' } { type: 'text/plain' } // Data { type: 'application/json' } ``` Save file IDs in your data for later retrieval: ```typescript theme={null} const fileId = await CDN.upload(file); await Data.create('documents', { title: 'Contract', fileId: fileId, // Store for later uploadedAt: new Date().toISOString() }); ``` Always handle potential errors: ```typescript theme={null} try { const file = await CDN.get(fileId); return { success: true, file }; } catch (error) { return { success: false, error: 'File not found' }; } ``` ## Complete Example ```typescript theme={null} import { LuaTool, CDN, Data, AI } from 'lua-cli'; import { z } from 'zod'; export class DocumentManagerTool implements LuaTool { name = "manage_document"; description = "Upload, retrieve, or analyze documents"; inputSchema = z.object({ action: z.enum(['upload', 'get', 'analyze']), fileId: z.string().optional(), content: z.string().optional(), filename: z.string().optional() }); async execute(input: z.infer) { switch (input.action) { case 'upload': { if (!input.content || !input.filename) { return { error: 'Content and filename required' }; } const file = new File([input.content], input.filename, { type: 'text/plain' }); const fileId = await CDN.upload(file); // Store reference await Data.create('documents', { fileId, filename: input.filename, uploadedAt: new Date().toISOString() }); return { success: true, fileId }; } case 'get': { if (!input.fileId) { return { error: 'File ID required' }; } const file = await CDN.get(input.fileId); const content = await file.text(); return { success: true, filename: file.name, content }; } case 'analyze': { if (!input.fileId) { return { error: 'File ID required' }; } const file = await CDN.get(input.fileId); const content = await file.text(); const summary = await AI.generate( 'Summarize this document concisely.', [{ type: 'text', text: content }] ); return { success: true, summary }; } } } } ``` If a CDN upload or retrieval isn't working as expected, log file metadata to inspect what was returned: ```typescript theme={null} const file = await CDN.get(input.fileId); console.log('CDN file:', file.name, file.type, `${file.size} bytes`); ``` Then run `lua logs --type skill --limit 5` after a test message. See the [Debugging Skills guide](/cli/debugging) for the full workflow. # Channels API Source: https://docs.heylua.ai/api/channels Send outbound messages from any execute context — tools, jobs, webhooks, and triggers ## Overview The Channels API lets your agent **initiate** messages on the channels it's connected to — WhatsApp, SMS, email, web chat, and more — from anywhere your code runs: a tool, a [scheduled job](/api/jobs), a [webhook](/api/luawebhook), or a [trigger](/api/luatool). It's the outbound half of a two-way conversation: inbound messages wake your agent, and `Channels.*` sends messages back out. ```typescript theme={null} import { Channels } from 'lua-cli'; // Send a free-form message on any connected channel await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Your order has shipped! 📦' }); // Send an email await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Order confirmation', text: 'Thanks for your order!' }); // Re-open a closed WhatsApp window with an approved template await Channels.whatsapp.sendTemplate({ to: { phoneNumber: '+14155552671' }, templateName: 'order_update', languageCode: 'en_US', messageContext: 'Told the customer their order shipped' }); ``` Every `Channels.*` send addressed to a person is **recorded to the recipient's conversation thread** with your agent. When the user replies, your agent picks up with full context — the outbound message is already part of the conversation it remembers. See [Proactive Messaging](/channels/proactive-messaging) for the full model. Sends addressed to a shared conversation (`to: { conversationId }`, Teams only) have no single recipient to attribute the message to, so they return `persisted: false` and are not written to agent memory. Free-form text on any connected channel Rich email — subject, HTML, cc/bcc, attachments Approved templates to start or re-open a conversation React with an emoji to a specific WhatsApp message ## Where you can call it `Channels.*` works in any execute context. Recipient resolution differs slightly by context: | Context | Available? | Notes | | ----------------------- | ---------- | ---------------------------------------------------------------------------------------------- | | **Tools** | ✅ | Has conversational context — you can target the current user with their `userId`, or any user. | | **Jobs** | ✅ | No conversational context — target an explicit `userId`, `phoneNumber`, or `email`. | | **Webhooks** | ✅ | No conversational context — target an explicit recipient. | | **Triggers** | ✅ | Same as webhooks — target an explicit recipient. | | **Pre/Post-processors** | ✅ | Available, but most sending happens in tools and jobs. | Pair `Channels.send` with a [scheduled job](/api/jobs) for time-based outreach (reminders, follow-ups, digests) — see the [Proactive Send recipe](/examples/proactive-send). ## Channels.send() Send a free-form text message on a connected channel. ```typescript theme={null} Channels.send(input: ChannelSendInput): Promise ``` The channel to send on. One of: `'whatsapp'`, `'sms'`, `'email'`, `'webchat'`, `'teams'`, `'instagram'`, `'messenger'`. The recipient. Provide **exactly one** of the fields below. A Lua user ID. Works on **every** channel — the recipient's channel address is resolved from their conversation history with your agent. A phone number in E.164 format (e.g. `+14155552671`). Valid for **`whatsapp`** and **`sms`** only — lets you reach a number with no prior conversation (cold start). An email address. Valid for the **`email`** channel only (cold start). For richer email, prefer [`Channels.email.send`](#channels-email-send). The message text. Supports the same [response formatting components](/formatting/introduction) (`:::` blocks) as inline replies, where the channel renders them. Optional send options — see [Options](#options). **Returns:** [`ChannelSendOutput`](#channelsendoutput) **Examples:** ```typescript theme={null} // In a tool — message the user you're talking to, on a specific channel await Channels.send({ channel: 'whatsapp', to: { userId: user._luaProfile.userId }, text: 'Here is the summary you asked for.' }); ``` ```typescript theme={null} // Reach a phone number with no prior conversation (WhatsApp/SMS) await Channels.send({ channel: 'sms', to: { phoneNumber: '+14155552671' }, text: 'Your verification code is 123456.' }); ``` ```typescript theme={null} import { Channels } from 'lua-cli'; // In a scheduled job (no conversation context) — target an explicit recipient await Channels.send({ channel: 'email', to: { email: 'customer@example.com' }, text: 'Your weekly report is ready.' }); ``` ## Channels.email.send() Send a rich email — subject, plain-text and/or HTML body, cc/bcc, and attachments. ```typescript theme={null} Channels.email.send(input: EmailSendInput): Promise ``` The recipient. Provide **exactly one** of `to.userId` or `to.email`. A Lua user ID — the email address is resolved from the user's conversation history. A literal email address (cold start). The email subject line. Plain-text body, sent as-is (the plain-text MIME part). Provide at least one of `text`, `html`, or `richBody`. HTML body, sent **as-is** — your exact markup, no template wrapper. Use this for designed / transactional emails. Rich body — markdown and [`:::` component blocks](/formatting/introduction) **rendered server-side** into the branded email template (the same rendering your agent's inline replies use). Use this for "send this message as a nice email." Mutually exclusive with `html`. Carbon-copy recipients. Blind-carbon-copy recipients. Files to attach. Each is `{ filename, contentType, url }` — the file is fetched from the public `url` at send time. Combined attachment size is capped at 28 MB. Threads this email as a reply (standard `In-Reply-To` header). Pass the `Message-ID` of the email you're replying to and mail clients group your message into that conversation — e.g. a thread per support ticket instead of one thread per user. When your agent handles an inbound email, the original `Message-ID` is available as `webhookPayload.messageId`; store it against your record and pass it back here on each follow-up. Honoured on the branded (existing-address) email channel. The conversation's `References` chain (standard threading header) — the accumulated `Message-ID`s of the thread. Append the latest `Message-ID` each turn so long threads stay correctly linked. Optional send options — see [Options](#options). **Returns:** [`ChannelSendOutput`](#channelsendoutput) (with `messageId` set to the provider message ID where available). **Example:** ```typescript theme={null} await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Your invoice', html: '

Invoice #12345

Total: $99.00

', cc: ['accounts@example.com'], attachments: [ { filename: 'invoice-12345.pdf', contentType: 'application/pdf', url: 'https://files.example.com/invoice-12345.pdf' } ] }); ``` Or let the platform render markdown into the branded template with `richBody`: ```typescript theme={null} await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Welcome aboard', richBody: '# Welcome!\n\nThanks for signing up — here is what to do next…' }); ``` **Threading a reply into an existing conversation** — keep follow-ups in the same email thread (e.g. one thread per ticket) by echoing the original message's `Message-ID`: ```typescript theme={null} // `webhookPayload.messageId` is the inbound email's Message-ID — capture it // when the ticket's first email arrives, then reuse it on every push. await Channels.email.send({ to: { email: 'tenant@example.com' }, subject: 'Re: Porch light (MT-2026-184)', richBody: 'Your technician is booked for Thursday 9am.', inReplyTo: ticket.rootMessageId, references: [ticket.rootMessageId] }); ``` ## Channels.whatsapp.sendTemplate() Send a **pre-approved WhatsApp template**. Use this to start a conversation, or to reach a user whose [24-hour messaging window](/channels/proactive-messaging#the-whatsapp-24-hour-window) has closed (where free-form sends aren't allowed). ```typescript theme={null} Channels.whatsapp.sendTemplate(input: WhatsAppTemplateSendInput): Promise ``` The recipient — exactly one of `to.userId` or `to.phoneNumber` (E.164). The name of an approved template on the agent's WhatsApp channel. List available templates with [`Templates.whatsapp.list`](/api/templates#list). The template language, e.g. `'en_US'`. Meta template component objects supplying the header / body / button parameter values — e.g. `{ type: 'BODY', parameters: [{ type: 'text', text: 'Tuesday at 3pm' }] }`. The component `type` is `'HEADER'`, `'BODY'`, or `'BUTTON'`; the parameter `type` is `'text'`, `'image'`, `'video'`, `'document'`, or `'coupon_code'`. A plain-text summary of what the template said. This is the text **recorded to the conversation thread** so your agent remembers the outreach when the user replies. Recommended whenever the template body isn't self-explanatory. Optional send options — see [Options](#options). **Returns:** [`ChannelSendOutput`](#channelsendoutput) **Example:** ```typescript theme={null} await Channels.whatsapp.sendTemplate({ to: { phoneNumber: '+14155552671' }, templateName: 'appointment_reminder', languageCode: 'en_US', components: [ { type: 'BODY', parameters: [{ type: 'text', text: 'Tuesday at 3pm' }] } ], messageContext: 'Reminded the customer about their Tuesday 3pm appointment' }); ``` `Channels.whatsapp.sendTemplate` is the **canonical** way to send a WhatsApp template as part of a conversation — it records the send to the recipient's thread and respects the recipient resolution above. The lower-level [`Templates.whatsapp.send`](/api/templates) (batch send by channel ID and phone numbers) remains available for bulk/campaign sends. ## Channels.whatsapp.sendReaction() React with an emoji to a specific WhatsApp message — the same reaction a person leaves by tapping and holding a message. This is also how the [Reaction formatting component](/formatting/reaction) sends on WhatsApp; call it directly when you need to react to a message outside of a normal reply (e.g. from a job or webhook). ```typescript theme={null} Channels.whatsapp.sendReaction(input: WhatsAppReactionSendInput): Promise ``` The recipient — exactly one of `to.userId` or `to.phoneNumber` (E.164). The vendor message ID of the WhatsApp message to react to (a `wamid....` value). Must be no more than 30 days old. Available from the channel webhook payload or the message's entry in conversation history. A single emoji to react with. Pass an empty string (`''`) to remove the agent's existing reaction from the message. Optional send options — see [Options](#options). **Returns:** [`ChannelSendOutput`](#channelsendoutput) **Example:** ```typescript theme={null} await Channels.whatsapp.sendReaction({ to: { userId: 'user_123' }, messageId: 'wamid.HBgLMTU1NTU1NTU1NTUVAgARGBI5QTNDQTVCM0Q0RUQ5RTU3RgA=', emoji: '👍' }); // Remove the reaction await Channels.whatsapp.sendReaction({ to: { userId: 'user_123' }, messageId: 'wamid.HBgLMTU1NTU1NTU1NTUVAgARGBI5QTNDQTVCM0Q0RUQ5RTU3RgA=', emoji: '' }); ``` **WhatsApp only, for now.** `sendReaction` isn't available on other channels — use the [Reaction formatting component](/formatting/reaction) for the broader multi-channel path (Instagram, Facebook Messenger, Slack, iMessage, web chat), which is ignored on channels without reaction support rather than throwing. ## Options All three methods accept an optional `options` object: Pin the send to a specific channel configuration (for agents with more than one channel of the same type). The configuration must belong to your agent. What to do when a WhatsApp free-form send hits a closed [24-hour window](/channels/proactive-messaging#the-whatsapp-24-hour-window): * **`'queue'`** (default) — queue the message and deliver it after the user re-engages (the API returns `queued: true`). * **`'fail'`** — reject the send so you can fall back to `Channels.whatsapp.sendTemplate`. ## ChannelSendOutput Every send method resolves to the same shape: ```typescript theme={null} interface ChannelSendOutput { deliveryId: string; // the delivery record; pass it to Channels.getStatus() status: DeliveryStatus; // where the delivery stands as the send returns delivered: boolean; // the channel accepted the message for delivery persisted: boolean; // the message was recorded to the agent's conversation thread queued?: boolean; // WhatsApp only: deferred until the recipient re-engages userId?: string; // the Lua user the message was recorded against identifier?: string; // the channel-native recipient (phone / email / etc.) messageId?: string; // provider message ID, where available warning?: string; // set when delivered but not persisted } ``` The delivery record for this send. It is stable for the whole life of the message, so store it if you want to answer "did that land?" later. Pass it to [`Channels.getStatus()`](#channels-getstatus). Where the delivery stands the moment the send returns. A live send returns `queued`, `accepted` or `sent`. Receipts move it further afterwards, which is what `Channels.getStatus()` reads. The channel accepted the message. Independent of `persisted`. The message was recorded to the recipient's conversation thread with your agent. A delivered-but-unpersisted send returns `delivered: true, persisted: false` plus a `warning` — it does **not** throw. WhatsApp only. `true` means the 24-hour window was closed and the message is queued — `delivered` stays `false` until the recipient re-engages, at which point the queued text is delivered and recorded. See [Proactive Messaging](/channels/proactive-messaging#the-whatsapp-24-hour-window). ## Delivery status A send tells you the message left. It cannot tell you the phone rang. That answer arrives later, as the provider reports back, and it lands on the delivery record the send returned an id for. ### Channels.getStatus() ```typescript theme={null} Channels.getStatus(deliveryId: string): Promise ``` ```typescript theme={null} const sent = await Channels.send({ channel: 'whatsapp', to: { phoneNumber: '+15551234567' }, text: 'Your order has shipped!' }); // ...later, in a job or a follow-up turn const delivery = await Channels.getStatus(sent.deliveryId); if (delivery.status === 'failed') { console.error(delivery.error?.category, delivery.error?.title); } else if (delivery.status === 'read') { console.log('Read at', delivery.readAt); } ``` Throws if no delivery with that id belongs to your agent. ### Channels.listDeliveries() ```typescript theme={null} Channels.listDeliveries(filter?: { userId?: string; status?: DeliveryStatus; channel?: string; since?: string | Date; limit?: number; // 1–200, defaults to 50 }): Promise ``` Newest first. ```typescript theme={null} const failures = await Channels.listDeliveries({ status: 'failed', since: new Date(Date.now() - 24 * 60 * 60 * 1000), limit: 100 }); const billing = failures.filter(d => d.error?.category === 'billing'); if (billing.length) { console.warn(`${billing.length} sends blocked on billing`); } ``` ### Status vocabulary Every channel reports into the same seven statuses. | Status | Meaning | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `queued` | WhatsApp only. The 24-hour window was closed, so the text is held and flushes when the recipient replies. | | `accepted` | The provider took the message and gave us an id. Receipts are still to come. | | `sent` | The provider handed it off to the recipient's network. On channels with no receipts (Slack, Teams, Front, webchat, AgentMail) this is where a successful send stops. | | `delivered` | It reached the recipient's device. | | `read` | The recipient opened it. WhatsApp only, and only when the recipient has read receipts on. | | `failed` | Terminal. `error` says why. | | `expired` | A `queued` WhatsApp message the recipient never re-engaged with. | Progress only moves forward. A receipt that arrives twice, or out of order, leaves the record where it already was, so polling `getStatus` is safe. The one exception is `failed`, which a provider may report after `sent` (a payment problem, say) but never after `delivered`. ### Error categories A failure carries a `DeliveryError`. `category` is ours and is the same across every provider, so you can branch on it without learning any vendor's code list. `code` is the vendor's own, kept for support tickets. `owner` says who has to act, and `retryable` says whether sending again could work. | Category | Means | Example vendor code | | ----------------- | ------------------------------------------------------------ | ---------------------- | | `window_closed` | Outside the channel's free-form window; send a template | Meta `131047` | | `unreachable` | No such recipient, or their device cannot be reached | Meta `131026` | | `billing` | The account cannot pay for this message | Meta `131042` | | `opted_out` | The recipient blocked or unsubscribed you | Meta `131050` | | `throttled` | Rate limited. Retryable | Meta `130429` | | `auth` | The channel's credentials are rejected or expired | Meta `190` | | `template` | The template is unapproved, missing or wrongly parameterised | Meta `132000`–`133999` | | `media` | The attachment was rejected or could not be fetched | Meta `131053` | | `invalid_request` | We sent something malformed | Meta `100` | | `compliance` | Blocked by policy, regulation or content rules | SES `Reject` | | `experiment` | Held back by a provider-side experiment | Meta `130472` | | `provider` | The provider failed on its own side. Often retryable | Meta `131000` | | `unknown` | The provider reported a code we do not classify yet | — | ```typescript theme={null} const delivery = await Channels.getStatus(sent.deliveryId); switch (delivery.error?.category) { case 'window_closed': await Channels.whatsapp.sendTemplate({ to: { phoneNumber }, templateName: 'order_update' }); break; case 'opted_out': await markUnsubscribed(delivery.recipient); break; case 'throttled': case 'provider': // delivery.error.retryable is true here break; } ``` ## Idempotency The SDK puts an `X-Idempotency-Key` on every send for you, so a retry inside one `Channels.send()` call cannot send the message twice. The key is what makes a *retry of your own* safe too. If your job crashes after a send and runs again, or a queue redelivers your handler, pass the same key both times and the second call sends nothing. It returns the first send's outcome instead, with the same `deliveryId`. The guard is for retries, one after the other: two calls with the same key that are in flight at the same instant can both go out, because the key is written once the vendor has accepted the send. Set your own key through the HTTP client's headers when you call the REST endpoint directly: ```bash theme={null} curl -X POST https://api.heylua.ai/developer/agents/$AGENT_ID/channels/send \ -H "Authorization: Bearer $LUA_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: order-4471-shipped" \ -d '{"channel":"whatsapp","to":{"phoneNumber":"+15551234567"},"text":"Your order has shipped!"}' ``` Derive the key from the thing you are messaging about, not from the attempt, or every retry mints a fresh key and sends again. Three rules worth knowing: * **The window is the delivery record's lifetime**, 180 days. There is no separate expiry. * **A key is scoped to one agent.** The same key from a different agent is a different key. * **A key whose first attempt failed replays the failure.** It does not resend. You already got an answer; sending again would be a second message, not a retry. ## Error handling `Channels.*` **throws** when a send is rejected (invalid recipient, unconfigured channel, provider rejection, or `onClosedWindow: 'fail'` on a closed window). Wrap calls in `try/catch` where a failure should be handled gracefully. A successful call may still report partial success — **`delivered: true` with `persisted: false`** (plus a `warning`) means the message went out but couldn't be recorded to the conversation thread. This is **not** an error and won't throw; check the flag if recording matters to your flow. ```typescript theme={null} try { const result = await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Quick update for you!' }); if (result.queued) { // Window closed — message will deliver when the user replies console.log('Queued; will deliver on re-engagement'); } else if (!result.persisted) { console.warn('Delivered but not recorded:', result.warning); } } catch (err) { // Rejected — e.g. unconfigured channel, invalid recipient, closed-window 'fail' console.error('Send failed:', err.message); } ``` **WhatsApp free-form sending is time-limited.** You can only send free-form WhatsApp messages within 24 hours of the recipient's last inbound message. Outside that window, use `Channels.whatsapp.sendTemplate` with an approved template — or rely on the default `onClosedWindow: 'queue'` behavior. See [Proactive Messaging](/channels/proactive-messaging) and [Channel Capabilities](/channels/channel-capabilities). ## Channels & recipients at a glance | Channel | Cold start (no prior conversation) | Warm only (`userId`) | | ----------- | -------------------------------------------------------- | ------------------------------------------------------ | | `whatsapp` | ✅ via `phoneNumber` (template required if window closed) | ✅ | | `sms` | ✅ via `phoneNumber` | ✅ | | `email` | ✅ via `email` | ✅ | | `webchat` | — | ✅ | | `teams` | — | ✅ — also `conversationId` for group chats and channels | | `instagram` | — | ✅ | | `messenger` | — | ✅ | See [Channel Capabilities](/channels/channel-capabilities) for per-channel limits, sender resolution, and compliance notes. ## TypeScript types ```typescript theme={null} type ChannelSendChannel = | 'whatsapp' | 'sms' | 'email' | 'webchat' | 'teams' | 'instagram' | 'messenger'; interface ChannelSendTarget { userId?: string; // any channel — the user's DIRECT conversation phoneNumber?: string; // whatsapp / sms (cold start) email?: string; // email (cold start) conversationId?: string; // teams — a shared conversation, not a person } interface ChannelSendOptions { channelIdentifier?: string; whatsapp?: { onClosedWindow?: 'queue' | 'fail' }; } interface ChannelSendInput { channel: ChannelSendChannel; to: ChannelSendTarget; text: string; options?: ChannelSendOptions; } interface WhatsAppTemplateSendInput { to: { userId?: string; phoneNumber?: string }; templateName: string; languageCode?: string; components?: Array>; messageContext?: string; options?: ChannelSendOptions; } interface WhatsAppReactionSendInput { to: { userId?: string; phoneNumber?: string }; messageId: string; emoji: string; options?: ChannelSendOptions; } interface EmailAttachmentInput { filename: string; contentType: string; url: string; } interface EmailSendInput { to: { userId?: string; email?: string }; subject?: string; text?: string; html?: string; richBody?: string; cc?: string[]; bcc?: string[]; attachments?: EmailAttachmentInput[]; inReplyTo?: string; references?: string[]; options?: ChannelSendOptions; } interface ChannelSendOutput { deliveryId: string; status: DeliveryStatus; delivered: boolean; persisted: boolean; queued?: boolean; userId?: string; identifier?: string; messageId?: string; warning?: string; } type DeliveryStatus = | 'queued' | 'accepted' | 'sent' | 'delivered' | 'read' | 'failed' | 'expired'; type DeliveryErrorCategory = | 'window_closed' | 'unreachable' | 'billing' | 'opted_out' | 'throttled' | 'auth' | 'template' | 'media' | 'invalid_request' | 'compliance' | 'experiment' | 'provider' | 'unknown'; interface DeliveryError { category: DeliveryErrorCategory; provider: 'meta' | 'vonage' | 'bird' | 'ses' | 'agentmail' | 'slack' | 'front' | 'teams' | 'pusher'; code: string; title: string; detail?: string; href?: string; retryable: boolean; owner: 'customer' | 'recipient' | 'lua' | 'vendor'; } interface DeliveryView { id: string; agentId: string; userId?: string; channel: string; provider: DeliveryError['provider']; channelIdentifier: string; recipient: string; providerMessageId?: string; conversationMessageId?: string; origin: string; templateName?: string; status: DeliveryStatus; error?: DeliveryError; events: { status: DeliveryStatus; at: string; source: 'send' | 'callback' | 'sweep' }[]; pricing?: { billable: boolean; category?: string; model?: string }; createdAt: string; updatedAt: string; deliveredAt?: string; readAt?: string; failedAt?: string; } ``` ## Next steps The full model: Channels.send vs user.send() vs templates, and per-channel windows Per-channel limits, sender resolution, and compliance Schedule outreach with defineJob + Channels.send List and batch-send approved WhatsApp templates # Data API Source: https://docs.heylua.ai/api/data Custom data storage with semantic vector search ## Overview The Data API allows you to store custom data in collections with powerful semantic search capabilities using vector embeddings. Returns **DataEntryInstance** objects with direct property access. Data is agent-owned collection storage: a collection can contain many independent entries. For one persistent record tied to a particular end user, use the [User API](/api/user) instead. ```typescript theme={null} import { Data } from 'lua-cli'; // Create with search indexing - returns DataEntryInstance const entry = await Data.create('movies', { title: 'Inception', director: 'Christopher Nolan' }, 'Inception Christopher Nolan sci-fi thriller dreams'); // Direct property access console.log(entry.title); // "Inception" console.log(entry.director); // "Christopher Nolan" console.log(entry.id); // "entry_abc123" // Instance methods await entry.update({ rating: 9.5 }, 'inception nolan 9.5 rating'); await entry.save(); await entry.delete(); // Semantic search - returns array of DataEntryInstance const results = await Data.search('movies', 'mind-bending thriller', 10, 0.7); // Direct array methods and property access results.forEach(entry => { console.log(`${entry.title}: ${entry.score * 100}% match`); }); const highScoring = results.filter(entry => entry.score > 0.8); const titles = results.map(entry => entry.title); ``` Access `entry.title` not `entry.data.title` Results include relevance scores Built-in `update()`, `patch()`, `unset()`, `save()`, and `delete()` Use `.map()`, `.filter()` on search results ## Return Shape Reference **Each method returns a different type.** The most common mistake is treating `Data.search()` results like `Data.get()` results — they have different shapes. | Method | Returns | How to use | | ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------- | | `Data.create()` | `DataEntryInstance` (Proxy) | `entry.fieldName` or `entry.data.fieldName` | | `Data.getEntry()` | `DataEntryInstance` (Proxy) | `entry.fieldName` or `entry.data.fieldName` | | `Data.search()` | `DataEntryInstance[]` — **flat array, no envelope** | `results.map(e => e.fieldName)`, `results.length` — **no `.data`, no `.count`** | | `Data.get()` | `{ data: Entry[], pagination }` — **envelope** | `result.data.map(e => e.data.fieldName)` — entries are raw, **not proxied** | | `Data.update()` | `{ status, message }` | Check `result.status === 'success'` | | `Data.delete()` | `{ status, message }` | Check `result.status === 'success'` | **Quick examples:** ```typescript theme={null} // ✅ Data.search → flat array const results = await Data.search('movies', 'thriller', 10, 0.7); results.forEach(entry => console.log(entry.title, entry.score)); // Proxy works console.log(results.length); // ✅ count // ❌ results.data → undefined // ❌ results.count → undefined // ✅ Data.get → { data, pagination } envelope const page = await Data.get('movies', {}, 1, 20); page.data.map(entry => entry.data.title); // raw entries: must use entry.data.field console.log(page.pagination.totalPages); // ❌ entry.title (without .data) → undefined on get() results // ✅ Data.create / Data.getEntry → DataEntryInstance with Proxy const entry = await Data.create('movies', { title: 'Inception' }, 'Inception Nolan'); console.log(entry.title); // Proxy shortcut ✅ console.log(entry.data.title); // Also works ✅ ``` ## Key Features Store any JSON data in named collections Semantic similarity search using AI embeddings No fixed schema - store any structure Query by field values with operators ## Methods ### create() Create a new entry in a collection. ```typescript theme={null} Data.create( collectionName: string, data: object, searchText?: string ): Promise ``` Name of the collection (e.g., 'movies', 'customers', 'articles') Any JSON-serializable object to store Text to index for vector search. Include all searchable content. **In a deployed agent, the third argument must be a plain string.** Passing an object as the third argument in a deployed agent fails with `searchText must be a string`. The options-object form (`{ searchText?, index? }`) currently works only in local development runs — see [Indexes](#indexes) for where index declarations stand today. **Returns:** ```typescript theme={null} { id: string; data: object; createdAt: number; updatedAt: number; searchText?: string; } ``` **Example:** ```typescript theme={null} const movie = await Data.create('movies', { title: 'The Matrix', year: 1999, director: 'Wachowski Sisters', genre: 'Sci-Fi', rating: 8.7 }, 'The Matrix 1999 Wachowski sci-fi action cyberpunk reality virtual'); console.log(movie.id); // "entry_abc123" ``` ### search() Semantic search using vector embeddings. ```typescript theme={null} Data.search( collectionName: string, searchText: string, limit?: number, scoreThreshold?: number ): Promise ``` Name of the collection to search Search text (natural language query) Maximum number of results to return Minimum similarity score (0-1). Higher = more similar. **Returns:** Array of `DataEntryInstance` objects, each with a `score` property. **Similarity Scores:** * `1.0` = Perfect match * `0.8-0.9` = Very similar * `0.6-0.7` = Somewhat similar * `<0.6` = Low similarity **Example:** ```typescript theme={null} // Finds movies even if query doesn't match exact words! const results = await Data.search('movies', 'mind-bending thriller', 5, 0.7); results.forEach(entry => { console.log(`${entry.title} - Relevance: ${entry.score}`); }); // Output: // Inception - Relevance: 0.92 // The Matrix - Relevance: 0.85 // Interstellar - Relevance: 0.78 ``` ### get() Filtering a **large** collection? Queries on unindexed fields slow down as the collection grows and eventually fail with an error naming the field. [Index declarations](#indexes) fix this, but they can currently be made in local development runs only — in a deployed agent, keep filtered collections small or filter through paginated reads. Retrieve entries with optional filtering and pagination. ```typescript theme={null} Data.get( collectionName: string, filter?: object, page?: number, limit?: number ): Promise ``` Name of the collection Bounded filter criteria for fields stored in the entry's `data` object Page number (1-indexed) Items per page (maximum 100) **Returns:** ```typescript theme={null} { data: Entry[]; pagination: { currentPage: number; totalPages: number; totalCount: number; limit: number; hasNextPage: boolean; hasPrevPage: boolean; nextPage: number | null; prevPage: number | null; }; } ``` **Examples:** ```typescript theme={null} // Get all const all = await Data.get('movies'); // With pagination const page2 = await Data.get('movies', {}, 2, 20); // With filter const recent = await Data.get('movies', { year: { $gte: 2020 } }); // Complex filter const sciFi = await Data.get('movies', { genre: 'Sci-Fi', rating: { $gte: 8.0 }, year: { $gte: 2000, $lte: 2020 } }); ``` ### getEntry() Retrieve a specific entry by ID. ```typescript theme={null} Data.getEntry( collectionName: string, entryId: string ): Promise ``` **Example:** ```typescript theme={null} const movie = await Data.getEntry('movies', 'entry_abc123'); console.log(movie.title); // "Inception" (direct property access via proxy) ``` ### update() Update an existing entry. ```typescript theme={null} Data.update( collectionName: string, entryId: string, data: object, searchText?: string ): Promise ``` Name of the collection ID of the entry to update Data to merge with existing entry Optional new text for vector search indexing. As with `create()`, a deployed agent accepts only a plain string here — the options-object form is a local-development capability today (see [Indexes](#indexes)). **Returns:** ```typescript theme={null} { status: string; // "success" message: string; // "Custom data entry updated" } ``` **Example:** ```typescript theme={null} // Update data and search text const result = await Data.update('movies', 'entry_abc123', { rating: 8.8, // Updated rating awards: ['Oscar'] // New field }, 'Inception Christopher Nolan oscar winner'); // Update data only await Data.update('movies', 'entry_abc123', { views: 1500 }); ``` Updates merge with existing data. Only the specified fields are updated; other fields are preserved. ## DataEntryInstance Methods When you retrieve or create data entries, you get a `DataEntryInstance` object with convenient instance methods. ### save() Save the current state of the data entry to the server. This is a convenience method that persists all changes made to the entry. ```typescript theme={null} entry.save(searchText?: string): Promise ``` Optional new text for vector search indexing **Returns:** Promise resolving to `true` if successful **Example:** ```typescript theme={null} const entry = await Data.getEntry('movies', 'entry_abc123'); // Modify properties directly entry.title = "Inception"; entry.rating = 9.0; entry.year = 2010; // Save all changes at once await entry.save(); // Or save with updated search text await entry.save('Inception 2010 Nolan sci-fi thriller dreams'); ``` The `save()` method provides a simpler workflow - modify properties then save, rather than calling `Data.update()` with the collection name and entry ID. ### update() (Instance Method) Update the entry using the instance method. ```typescript theme={null} entry.update(data: object, searchText?: string): Promise ``` **Returns:** Promise resolving to the updated data object **Example:** ```typescript theme={null} const entry = await Data.getEntry('movies', 'entry_abc123'); // Update with new search text const updatedData = await entry.update( { rating: 9.5, review: 'Mind-bending masterpiece' }, 'inception nolan 9.5 rating masterpiece' ); console.log(updatedData.rating); // 9.5 // Update data only (keeps existing search text) await entry.update({ views: 1000 }); ``` ### patch() and unset() Atomically set and remove top-level fields from an entry. You can also replace the semantic-search text, or clear it with `null`. ```typescript theme={null} entry.patch(mutation: { set?: Record; unset?: string[]; searchText?: string | null; }): Promise> entry.unset(...fields: string[]): Promise> ``` A field mutation must change at least one field, unless `searchText` is changed in the same request. `set` and `unset` cannot contain the same field, and field names must be non-empty and cannot start with `$` or contain `.` or a null byte. ```typescript theme={null} const entry = await Data.getEntry('movies', 'entry_abc123'); await entry.patch({ set: { rating: 9.5 }, unset: ['legacyRating'], searchText: 'Inception Christopher Nolan acclaimed sci-fi thriller' }); // Remove fields without replacing the entire entry await entry.unset('temporaryNote', 'importBatch'); // Remove the search text and its semantic-search vector await entry.patch({ searchText: null }); ``` As with User data, `null` is a stored value inside `set`; use `unset` for deletion. Existing `update()` merge behavior is unchanged. ### delete() (Instance Method) Delete the entry using the instance method. ```typescript theme={null} entry.delete(): Promise ``` **Example:** ```typescript theme={null} const entry = await Data.getEntry('movies', 'entry_abc123'); await entry.delete(); ``` ## Static Methods ### delete() Delete an entry using the static method. ```typescript theme={null} Data.delete( collectionName: string, entryId: string ): Promise ``` **Example:** ```typescript theme={null} await Data.delete('movies', 'entry_abc123'); ``` ## Use Cases ### Knowledge Base ```typescript theme={null} export class CreateArticleTool implements LuaTool { async execute(input: any) { // Create searchable article const article = await Data.create('kb_articles', { title: input.title, content: input.content, category: input.category, tags: input.tags }, `${input.title} ${input.content} ${input.tags.join(' ')}`); return { articleId: article.id }; } } export class SearchArticlesTool implements LuaTool { async execute(input: any) { // Semantic search const results = await Data.search( 'kb_articles', input.query, 10, 0.7 ); return { articles: results.map(entry => ({ id: entry.id, title: entry.title, content: entry.content.substring(0, 200), relevance: Math.round(entry.score * 100) + '%' })) }; } } ``` ### Customer CRM ```typescript theme={null} export class CreateCustomerTool implements LuaTool { async execute(input: any) { const customer = await Data.create('customers', { name: input.name, email: input.email, company: input.company, status: 'active', createdAt: new Date().toISOString() }, `${input.name} ${input.company} ${input.email}`); // Log interaction await Data.create('interactions', { customerId: customer.id, type: 'created', notes: 'Initial contact', timestamp: new Date().toISOString() }); return { customerId: customer.id }; } } ``` ### Task Management ```typescript theme={null} export class CreateTaskTool implements LuaTool { async execute(input: any) { const task = await Data.create('tasks', { title: input.title, description: input.description, status: 'pending', priority: input.priority, createdAt: new Date().toISOString() }, `${input.title} ${input.description}`); return { taskId: task.id }; } } export class SearchTasksTool implements LuaTool { async execute(input: any) { const results = await Data.search('tasks', input.query, 20, 0.6); return { tasks: results.map(entry => ({ id: entry.id, title: entry.title, description: entry.description, status: entry.status, priority: entry.priority, relevance: entry.score })) }; } } ``` ## Filter Operators Data filters use the platform-wide [Lua Query language](/api/query). The grammar, validation, limits, and errors are identical to every other API that accepts a `filter`. ```typescript theme={null} // Comparison { age: { $eq: 25 } } // Equal { age: { $ne: 25 } } // Not equal { age: { $gt: 25 } } // Greater than { age: { $gte: 25 } } // Greater than or equal { age: { $lt: 25 } } // Less than { age: { $lte: 25 } } // Less than or equal // Logical { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] } { $or: [{ status: 'active' }, { status: 'pending' }] } // Array { tags: { $in: ['urgent', 'important'] } } { tags: { $nin: ['spam', 'archived'] } } { tags: ['urgent', 'important'] } // Shorthand for $in // Existence { email: { $exists: true } } ``` See [Lua Query security, errors, and resource limits](/api/query#security-and-errors) for the common contract. ## Indexes **Local development only, for now.** Index declarations are accepted in local development runs, but the deployed runtime does not accept them yet: in a deployed agent, `Data.create()` and `Data.update()` take a plain `searchText` string as the third argument, and **passing an object as the third argument in a deployed agent fails with `searchText must be a string`**. Treat this section as a preview of the local-development behavior, and keep the third argument a string in code you deploy. Every agent's Data collections live in shared storage. Filtered queries (`Data.get(collection, { field: value })`) stay fast only if the filtered field is **indexed for your agent** — without one, query time grows with your collection until requests exceed the platform's 5-second budget and fail. Declare the fields you filter on **where you store data**: ```typescript theme={null} // Single fields — one index each await Data.create('inference_cache', doc, { index: ['business_id'] }); // Compound (fields filtered TOGETHER) — nested array await Data.create('orders', doc, { index: [['country', 'business_id']] }); // update() and patch() accept the same option and refresh your declarations await Data.update('orders', id, changes, { index: [['country', 'business_id']] }); ``` ### How it works * **Asynchronous**: the index builds within minutes of the first declaration. Don't judge query speed in the first moments after declaring. * **Agent-scoped**: your index contains only your agent's rows — declaring costs other agents nothing. * **Self-maintaining**: declarations are idempotent; declare on every write. An index stays alive while your agent *uses* it — declaring writes **or** matching filtered reads both count — and is removed automatically \~14 days after all usage stops. No cleanup code, ever. * **Compound = leftmost prefix**: `[['country', 'business_id']]` serves filters on `country` and on `country + business_id`, but **not** `business_id` alone — declare that separately if you filter it alone. ### Limits | Limit | Value | | --------------------- | ----- | | Fields per index | 2 | | Declarations per call | 3 | | Indexes per agent | 5 | Over-limit or invalid declarations are **rejected, never silently trimmed** — check status to see why. ### Checking status The collections listing includes your declarations and their state: ```typescript theme={null} const { data: collections } = await Data.collections(); for (const col of collections) { for (const idx of col.indexes ?? []) { console.log(col.name, idx.fields, idx.status, idx.error ?? ''); // status: pending | building | ready | failed | rejected // error explains failed/rejected (e.g. "too many fields (max 2)") } } ``` ### When you forget A filtered query that exceeds the time budget on an **undeclared** field fails with an error naming the collection, the field, and the exact declaration to add. In a local development run, copy it into your `Data.create` call and the index builds automatically; in a deployed agent the declaration cannot be applied yet — restructure the read (smaller collection, tighter pagination) until deployed index support lands. ### Caveats * Declare **scalar** fields. Indexing a field that holds arrays degrades the index (and a compound where *both* fields hold arrays will reject writes with a MongoDB "parallel arrays" error). * Field paths may be dotted (`profile.tier`) but not numeric positions (`items.0.sku`), and never `$`-prefixed. ## Best Practices Include all searchable content in searchText: ```typescript theme={null} const searchText = [ item.title, item.description, item.category, item.tags.join(' '), item.author ].filter(Boolean).join(' '); await Data.create('items', item, searchText); ``` * `0.8+`: High precision, few results * `0.7`: Balanced (recommended default) * `0.6`: More results, lower precision * `<0.6`: May return irrelevant results Use consistent field names across entries: ```typescript theme={null} // ✅ Good - Consistent await Data.create('items', { name: 'Item 1', price: 10.99, inStock: true }); // ❌ Bad - Inconsistent await Data.create('items', { title: 'Item 2', // Different field name cost: 15.99, // Different field name available: true // Different field name }); ``` Track when entries are created/modified: ```typescript theme={null} await Data.create('items', { ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); ``` ## Vector Search Tips Vector search uses AI to understand meaning, not just match keywords. **Example:** * Query: "affordable laptop for students" * Finds: "budget-friendly notebook for college" * Even though no words match exactly! * Knowledge bases * FAQs * Product recommendations * Content discovery * Document search 1. Include synonyms in searchText 2. Use natural language queries 3. Adjust threshold based on results 4. Test with real user queries If your Data results don't look right, log the raw return value before transforming it: ```typescript theme={null} const results = await Data.search('collection', input.query); console.log('Data.search result:', JSON.stringify(results, null, 2)); ``` Then run `lua logs --type skill --limit 5` after sending a test message to see the actual shape at runtime. See the [Debugging Skills guide](/cli/debugging) for the full 5-step workflow. ## Next Steps See working examples Complete tutorial using Data API Inspect runtime return values # LuaDevices Source: https://docs.heylua.ai/api/device-definition Define server-side devices and device triggers with defineDevice() and defineDeviceTrigger() ## Overview The Device Definition API gives you two helpers for declaring how your agent talks to a physical or virtual device: * **`defineDevice(config)`** — declares a device, its commands, and (optionally) its built-in triggers. * **`defineDeviceTrigger(config)`** — declares a standalone trigger primitive (versioned, pushed independently). ```typescript theme={null} import { defineDevice, defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; ``` For the device-side **client library** (the code that runs on the device itself — Node, MQTT, MicroPython), see [Device Client](/api/luadeviceclient) and the [Devices tab](/devices/overview). *** ## `defineDevice(config)` Declares a device. Devices have commands (agent → device) and optionally triggers (device → agent). ```typescript theme={null} import { defineDevice } from 'lua-cli'; import { z } from 'zod'; export const labelPrinter = defineDevice({ name: 'label-printer', description: 'Warehouse label printer (Zebra GK420d)', commands: { print: { description: 'Print a shipping label', schema: z.object({ orderId: z.string(), labelData: z.object({ address: z.string(), tracking: z.string(), }), }), }, eject: { description: 'Eject the current label', schema: z.object({}), }, }, triggers: { paperLow: { description: 'Fires when paper drops below threshold', payloadSchema: z.object({ level: z.number() }), execute: async (payload, { agent, device }) => { await agent.chat(`Printer ${device.name} paper low: ${payload.level}%`); }, }, }, }); ``` ### Configuration — `LuaDeviceConfig` Unique device name. Used in `lua devices` and addressing from agent code. Human-readable description shown in the admin dashboard. Optional group label for organizing devices (e.g. `'printers'`, `'sensors'`). `lua devices list --group ` filters by this. Map of command name → command config. Each command is a callable the agent can invoke on the device. Map of trigger name → trigger config. Built-in triggers tied to this device. For triggers shared across devices, use [`defineDeviceTrigger`](#definedevicetrigger-config) instead. ### Command Shape — `DeviceCommandConfig` What this command does. Used by the agent's LLM to decide when to invoke. Zod schema for the command's input. Validated before the command leaves the agent. Per-command timeout in milliseconds. Defaults to the device-wide timeout (`30000`). ### Trigger Shape — `DeviceTriggerConfig` What this trigger represents. Used in trigger discovery and admin UI. Zod schema for the trigger payload. Validated when the device fires the trigger. Handler invoked when the trigger fires. Receives the validated payload and a context with `agent` (for invoking the agent) and `device` (the device that fired). *** ## `defineDeviceTrigger(config)` Declares a **standalone** device trigger as a first-class primitive. Use this when a trigger isn't bound to a single device — for example, a trigger that any device in a group can fire, or a trigger that's pushed/versioned independently from its associated device. ```typescript theme={null} import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const paperLowAlert = defineDeviceTrigger({ name: 'paper-low-alert', description: 'Generic paper-low handler for any printer', payloadSchema: z.object({ deviceName: z.string(), level: z.number(), }), execute: async (payload, { agent }) => { await agent.chat(`Paper low on ${payload.deviceName}: ${payload.level}%`); }, }); ``` Standalone triggers are pushed via `lua push device-trigger` (or as part of `lua push all`) and managed through the standard CLI surfaces. ### Configuration — `LuaDeviceTriggerConfig` Unique trigger name. Allowed characters: `a-z`, `0-9`, `_`, `-`. Must start with a letter. What this trigger represents. Zod schema for the trigger payload. Handler invoked when the trigger fires. *** ## Wiring Up to an Agent ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import { labelPrinter } from './devices/label-printer'; import { paperLowAlert } from './triggers/paper-low-alert'; export const agent = new LuaAgent({ name: 'warehouse-agent', devices: [labelPrinter], deviceTriggers: [paperLowAlert], // ... }); ``` ## Invoking Commands from Tools Inside a skill tool, address a device by name and call its command: ```typescript theme={null} class PrintLabelTool extends LuaTool { name = 'printLabel'; description = 'Print a shipping label'; inputSchema = z.object({ orderId: z.string() }); async execute({ orderId }, ctx) { const order = await Data.get('orders', orderId); await ctx.devices['label-printer'].commands.print({ orderId, labelData: { address: order.shipping, tracking: order.tracking }, }); return { printed: true }; } } ``` ## Local Testing Use `lua devices test` and `lua devices test-trigger` to exercise commands and triggers without involving real hardware: ```bash theme={null} lua devices test --device-name label-printer lua devices test-trigger --device-name label-printer --payload '{"level":15}' ``` ## Related * [Devices Overview](/devices/overview) * [Device Client](/api/luadeviceclient) — the device-side library * [Devices Command](/cli/devices-command) * [Self-Describing Commands](/devices/self-describing-commands) * [Device Triggers](/devices/triggers) # Environment Utilities Source: https://docs.heylua.ai/api/environment Secure environment variable management ## env() Safely access environment variables in your tools. ```typescript theme={null} import { env } from 'lua-cli'; const apiKey = env('API_KEY'); const baseUrl = env('API_BASE_URL') || 'https://default.com'; ``` ## Function Signature ```typescript theme={null} env(key: string): string | undefined ``` Environment variable name to retrieve **Returns**: `string | undefined` - Value of the environment variable, or `undefined` if not set ## Loading Priority Environment variables are loaded in this order (later overrides earlier): Variables from your shell/system ```bash theme={null} export API_KEY=value ``` Variables from project `.env` file ```bash theme={null} # .env API_KEY=value ``` For production, use `lua env` command to manage variables on the server. ## Examples ### Basic Usage ```typescript theme={null} import { env } from 'lua-cli'; export class MyTool implements LuaTool { async execute(input: any) { const apiKey = env('STRIPE_API_KEY'); if (!apiKey) { throw new Error('STRIPE_API_KEY not configured'); } // Use the API key... const response = await fetch('https://api.stripe.com/v1/...', { headers: { 'Authorization': `Bearer ${apiKey}` } }); return await response.json(); } } ``` ### With Default Values ```typescript theme={null} const apiUrl = env('API_BASE_URL') || 'https://api.example.com'; const maxRetries = parseInt(env('MAX_RETRIES') || '3'); const debug = env('DEBUG') === 'true'; const timeout = Number(env('TIMEOUT') || 5000); ``` ### Multiple Environment Variables ```typescript theme={null} export class EmailTool implements LuaTool { async execute(input: any) { // Get all required variables const apiKey = env('SENDGRID_API_KEY'); const fromEmail = env('FROM_EMAIL'); const fromName = env('FROM_NAME') || 'Support Team'; // Validate required variables if (!apiKey) { throw new Error('SENDGRID_API_KEY is required'); } if (!fromEmail) { throw new Error('FROM_EMAIL is required'); } // Use variables... } } ``` ### Environment-Specific Configuration ```typescript theme={null} export class ApiTool implements LuaTool { async execute(input: any) { const environment = env('NODE_ENV') || 'development'; // Use different config based on environment const apiKey = environment === 'production' ? env('PROD_API_KEY') : env('DEV_API_KEY'); const baseUrl = environment === 'production' ? 'https://api.example.com' : 'https://api-dev.example.com'; // Use environment-specific values... } } ``` ## Setting Variables ### Method 1: .env File (Local Development) Create `.env` in project root: ```bash theme={null} # .env STRIPE_API_KEY=sk_test_abc123 SENDGRID_API_KEY=SG.xyz789 API_BASE_URL=https://api.example.com MAX_RETRIES=3 DEBUG=true ``` Add `.env` to `.gitignore` - never commit secrets! Create `.env.example` for documentation: ```bash theme={null} # .env.example STRIPE_API_KEY=your_stripe_key_here SENDGRID_API_KEY=your_sendgrid_key_here API_BASE_URL=https://api.example.com ``` ### Method 2: System Environment Set in your shell: ```bash theme={null} # Temporary (current session) export API_KEY=value # Permanent (add to ~/.bashrc or ~/.zshrc) echo 'export API_KEY=value' >> ~/.bashrc ``` ## Best Practices ```typescript theme={null} const apiKey = env('REQUIRED_KEY'); if (!apiKey) { throw new Error( 'REQUIRED_KEY environment variable is not set. ' + 'Please add it to your .env file or use `lua env` for production' ); } ``` ```typescript theme={null} // ✅ Good env('STRIPE_API_KEY') env('SENDGRID_API_KEY') env('WEATHER_API_KEY') // ❌ Bad env('KEY1') env('SECRET') env('TOKEN') ``` ```typescript theme={null} // Configuration with sensible defaults const apiUrl = env('API_URL') || 'https://api.example.com'; const timeout = parseInt(env('TIMEOUT') || '5000'); const retries = parseInt(env('MAX_RETRIES') || '3'); // But NOT for secrets const apiKey = env('API_KEY'); // No default! if (!apiKey) throw new Error('API_KEY required'); ``` ```typescript theme={null} const apiKey = env('API_KEY'); // ❌ Bad - Exposes secret console.log('API Key:', apiKey); // ✅ Good - Masked console.log('API Key:', apiKey ? '***' : 'not set'); console.log('API Key configured:', !!apiKey); ``` Environment variables are always strings. Convert when needed: ```typescript theme={null} // Numbers const port = parseInt(env('PORT') || '3000'); const timeout = Number(env('TIMEOUT') || 5000); // Booleans const debug = env('DEBUG') === 'true'; const enabled = env('FEATURE_ENABLED') !== 'false'; // Arrays const hosts = (env('ALLOWED_HOSTS') || '').split(','); ``` ## Common Patterns ### Pattern: Required Variable ```typescript theme={null} function getRequiredEnv(key: string): string { const value = env(key); if (!value) { throw new Error(`${key} environment variable is required`); } return value; } // Usage const apiKey = getRequiredEnv('STRIPE_API_KEY'); ``` ### Pattern: Validation ```typescript theme={null} const apiKey = env('API_KEY'); if (apiKey && !apiKey.startsWith('sk_')) { throw new Error('API_KEY must start with sk_'); } if (apiKey && apiKey.length < 32) { throw new Error('API_KEY is too short'); } ``` ### Pattern: Caching ```typescript theme={null} export class MyTool implements LuaTool { private static cachedApiKey: string | null = null; private getApiKey(): string { if (!MyTool.cachedApiKey) { MyTool.cachedApiKey = env('API_KEY'); if (!MyTool.cachedApiKey) { throw new Error('API_KEY not configured'); } } return MyTool.cachedApiKey; } async execute(input: any) { const apiKey = this.getApiKey(); // Use cached key... } } ``` ## Security **Never commit secrets to version control!** * Add `.env` to `.gitignore` * Use `.env.example` for documentation * Store production secrets using `lua env` command (server-managed) * Rotate keys regularly ### .gitignore ``` # .gitignore .env .env.local .env.*.local ``` ## Troubleshooting **Problem**: `env('MY_VAR')` returns `undefined` **Solutions**: 1. Check spelling in `.env` file 2. Ensure `.env` is in project root 3. Restart CLI command (variables loaded at startup) 4. For production, use `lua env` to verify variables on server **Problem**: Updated `.env` but value unchanged **Solution**: Restart the command: ```bash theme={null} # Stop (Ctrl+C) lua chat # Start again ``` Or use `lua env` to verify and update variables ## Next Steps Complete guide to configuration See environment variables in action # Inbox API Source: https://docs.heylua.ai/api/inbox Push approval requests, notices, and connection-fix cards onto a user's desk from any execute context ## Overview The Inbox API lets your agent **ask for something and wait** — instead of hoping the user is in the conversation right now. `User.Inbox.push()` puts a card on the user's desk: an approval to click, a notice to read, or a broken integration to reconnect. You get a receipt back immediately; the user deals with the card whenever they next open their inbox. ```typescript theme={null} import { User } from 'lua-cli'; const receipt = await User.Inbox.push({ title: 'Approve the Q3 renewal quote', body: 'Northstar Ltd renewal is ready to send at $48,000.', actions: ['approve'], key: 'northstar-q3-renewal', }); // { outcome: 'deposited', kind: 'input_request', key: 'northstar-q3-renewal' } ``` This is the counterpart to [Channels](/api/channels). `Channels.*` sends a **message** into a conversation and expects the user to read it there. `Inbox.push` files a **task** that survives being ignored — it stays on the desk until it is acted on or expires. Options the user resolves in one click A finished-work or heads-up card An integration the agent needs reconnected ## Where you can call it `User.Inbox.push()` takes **no recipient** — the card always goes to the user of the current execution context. That makes it available exactly where your code already has an ambient user. | Context | Available? | Who gets the card | | ----------------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | | Tool `execute` | ✅ | The user in the current conversation | | [Dynamic job](/api/jobs) (`Jobs.create`) | ✅ | The user who triggered the job | | Local run against your agent | ✅ | **You** — the signed-in developer | | Tool in a [trigger](/api/luatrigger)-fired turn | ✅ | The trigger's **bound user** — its creator, or the installer for template triggers | | [Pre-defined `LuaJob`](/api/luajob) | ❌ | Context-less — no ambient user | | [Webhook `execute`](/api/luawebhook) | ❌ | Context-less — no ambient user | The recipient is derived from the execution context or your own credentials — never from the payload, and it cannot be overridden. This is the same context split that makes `User.get()` work in a tool but require an explicit `User.get(userId)` in a webhook's `execute`: surfaces with no ambient user have nothing for `Inbox.push` to target, and a push from one fails with `no_user_context`. Incoming webhooks are no longer automatically context-less, though: a [trigger](/api/luatrigger)-fired agent turn runs as the trigger's **bound user** — the developer who created it, or the installer for template triggers — so pushes from tools running in that turn reach a real desk. It's only a `LuaWebhook`'s own `execute` function (and a pre-defined `LuaJob`) that has no ambient user; from there, message a specific user directly with [`user.send()`](/api/user) or [Channels](/api/channels) instead. ## push(input) ```typescript theme={null} const receipt = await User.Inbox.push(input); ``` ### Parameters The card's first line. Trimmed and capped at 140 characters. The human explanation — the card's context line, and the prose in the detail pane. Capped at 1000 characters. Longer text shown only when the user opens the card. Capped at 4000 characters. A source link for the card — an issue URL, a document permalink. Must be `http://` or `https://`. Rendered as a link the user can follow; never opened automatically. How loudly the card announces itself. `urgent` is rate-limited — see [Limits](#limits). What the card offers. `'approve'` gives the user a decision to make; `'fix'` requires `connection`. See [Card kinds](#card-kinds) for how this maps to what the user sees. Two to four one-click answers. Labels are capped at 60 characters, descriptions at 200. Anything past the fourth option is dropped. Required with `actions: ['fix']`. `type` is the integration's catalog slug (`'google-calendar'`), `name` is what the user should recognise (`'Google Calendar'`). Your idempotency and revision handle. 1–120 characters of `A–Z`, `a–z`, `0–9`, `.`, `_`, `:`, `-`. Push the same `key` again and you land on the existing card instead of knocking a second time. Omit it for one-shot cards. The conversation the card should hand off into when the user acts on it. Defaults to the current conversation. ### Returns `deposited` — a new card is on the desk. `updated` — an existing card with this `key` was revised in place. `exists` — a card with this identity was already there, unchanged. `capped` — the push was refused and nothing was filed: the daily limit is spent, or the user's organization has turned agent pushes off. `reason` says which. Which card kind the push routed to — echoed even on `capped`, where it reports the kind that *would* have been filed. The key the card is filed under — your `key` if you supplied one, otherwise the generated one. Reuse it to revise the card later. Only present on `capped`. A short explanation you can surface to the agent author. ## Card kinds You don't pick the kind directly — it follows from what you ask for, in this order: Supplying `options` (2–4) files an `input_request`. So does `actions: ['approve']` **without** options — the Approve/Decline pair is synthesized for you, so the card still resolves in one click. `actions: ['fix']` with `connection: { type, name }` files a `connection_fix` — the card that walks the user through reconnecting the integration your agent is blocked on. Anything with no options and no fix — including `actions: ['redirect']` — files an `agent_notice`: work you finished, something the user should know about. If you supply both `options` and `actions: ['fix']`, the options win — the question card is checked first. ## Limits Inbox pushes are budgeted so an agent cannot train its user to ignore the inbox. The numbers below are platform defaults and may be tuned; the behaviors — resolve to `capped`, demote, never throw — are the contract. | Limit | Default | Behaviour when exceeded | | ------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cards per agent, per user, per day | 5 per kind | The push resolves to `{ outcome: 'capped' }` — it does **not** throw | | `priority: 'urgent'` per agent, per user, per day | 2 | The card still lands, demoted to `high` — never dropped. Demotion also drops the urgent-only **email leg**: the third urgent push of the day is device-push only | | Organization kill switch | Off (pushes allowed) | An org admin can disable agent pushes entirely — every push resolves to `{ outcome: 'capped' }` with a reason | Budgets are shared with the agent's other inbox activity of the same class, so a question pushed from code spends the same allowance as a question the agent asks on its own. Treat `capped` as an expected outcome, not an error. Branch on it and fold the information into your run summary instead of retrying: ```typescript theme={null} const receipt = await User.Inbox.push({ title, body }); if (receipt.outcome === 'capped') { return `Digest ready, but the inbox is full for today: ${receipt.reason}`; } ``` ## Notifications and quiet hours A **fresh** card (`outcome: 'deposited'`) notifies the user's devices; `urgent` cards also send an email. The email rule applies **after** the daily urgent clamp — a push demoted to `high` sends no email. Delivery is **best-effort**: a notification can be lost — no registered devices, an offline phone, a delivery hiccup — without affecting the card, and the receipt doesn't report delivery. Revisions (`updated`), duplicates (`exists`), and capped pushes never notify — a revision updates the card quietly. **Quiet hours drop notifications — they don't defer them.** If the user has quiet hours set and your push lands inside the window, there is **no device push and no email** — not even for `urgent`, and there is no catch-up when the window ends. The card itself always lands and is the durable record; the user sees it next time they open their desk. Rely on the card, not the notification. Quiet hours are honored fail-open: if the platform can't read the user's preference, it delivers rather than risk silently suppressing every notification. So quiet hours are a strong promise to the user, not an absolute guarantee to your code — one more reason the card, not the notification, is the contract. ## Revising a card Give a card a `key` and later pushes with that same `key` revise it in place rather than filing a second one. The user is not notified again for an unchanged card. ```typescript theme={null} // Monday — file the card await User.Inbox.push({ key: 'weekly-pipeline-review', title: 'Pipeline review ready', body: '12 deals need a status update.', }); // Tuesday — same key, new numbers: the existing card updates await User.Inbox.push({ key: 'weekly-pipeline-review', title: 'Pipeline review ready', body: '9 deals need a status update.', }); // → { outcome: 'updated', kind: 'agent_notice', key: 'weekly-pipeline-review' } ``` ## Errors A full inbox resolves to `capped`. Everything else throws, with a `code` you can branch on: | `code` | Meaning | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `invalid_input` | Missing `title`/`body`, a non-http `deeplink`, a `key` outside the allowed charset, or `actions: ['fix']` without `connection` | | `no_user_context` | The execution context has no user to deliver to | | `deposit_failed` | The card could not be filed — safe to retry | ```typescript theme={null} try { await User.Inbox.push({ title, body, actions: ['fix'], connection }); } catch (error: any) { if (error.code === 'invalid_input') { // Fix the payload — retrying unchanged will fail the same way } throw error; } ``` ## Examples ### Ask for an approval before acting ```typescript theme={null} import { LuaTool, User } from 'lua-cli'; import { z } from 'zod'; export class RequestRefundApproval implements LuaTool { name = 'request_refund_approval'; description = 'Ask the account owner to approve a refund above the auto-approve threshold'; inputSchema = z.object({ orderId: z.string(), amount: z.number(), }); async execute({ orderId, amount }: z.infer) { const receipt = await User.Inbox.push({ title: `Approve a $${amount} refund`, body: `Order ${orderId} is above the auto-approve limit and needs a decision.`, detail: 'Approving issues the refund immediately and emails the customer.', options: [ { label: 'Approve refund', description: 'Issue it now' }, { label: 'Decline', description: 'Keep the order as is' }, ], key: `refund-approval:${orderId}`, priority: 'high', }); if (receipt.outcome === 'capped') { return 'Could not file the approval today — the daily inbox limit was reached.'; } return `Approval requested. You'll be asked in your inbox.`; } } ``` ### Report finished work from a recurring job Dynamic jobs carry the user who created them, so a push inside one lands on that user's desk: ```typescript theme={null} import { Jobs, User } from 'lua-cli'; await Jobs.create({ name: 'nightly-digest', schedule: { type: 'cron', expression: '0 6 * * *' }, execute: async () => { const summary = await buildDigest(); await User.Inbox.push({ title: 'Nightly digest ready', body: `${summary.count} items processed overnight.`, detail: summary.text, deeplink: summary.url, key: 'nightly-digest', }); }, }); ``` Because the `key` is stable, tomorrow's run revises today's card instead of stacking a second one. ### Ask the user to reconnect an integration ```typescript theme={null} const receipt = await User.Inbox.push({ title: 'Reconnect Google Calendar', body: "I can't read your availability — the calendar connection expired.", actions: ['fix'], connection: { type: 'google-calendar', name: 'Google Calendar' }, key: 'calendar-reconnect', }); // → { outcome: 'deposited', kind: 'connection_fix', key: 'calendar-reconnect' } ``` ## Best Practices A digest, a review, a status card — anything your agent produces on a schedule should carry the same `key` every run. Without one, each run files a new card and the inbox becomes a log. Only two urgent cards per user per day survive at that priority; the rest are demoted. Spending the allowance on routine cards means the genuinely urgent one arrives looking ordinary. A notice asking "let me know if this is okay" needs the user to open a conversation and type. Two to four `options` turn the same ask into one click, and the answer comes back into the thread. `capped` means the budget is spent for the day, so an immediate retry fails identically. Put the information in your return value instead — the user still gets it, through the conversation. Users scan the first line. `Approve the Q3 renewal quote` is actionable at a glance; `Action required` is not. ## TypeScript Support The input and receipt are fully typed at the call site, so `receipt.outcome` narrows correctly without any annotation. If you need the shapes as named types, derive them from the method: ```typescript theme={null} import { User } from 'lua-cli'; type InboxPushInput = Parameters[0]; type InboxPushReceipt = Awaited>; async function notify(input: InboxPushInput): Promise { return User.Inbox.push(input); } ``` ## Next Steps Read and write the user data behind the card Send a message into the conversation instead Schedule the work that files the card Multi-agent delegation ## See also * [Proactive Inbox recipe](/examples/proactive-inbox) — a full monitoring skill: notices that revise in place, approvals, connection fixes, and `capped` handled gracefully * [User API](/api/user) — profile, storage, and `user.send()` for in-conversation messages # Integrations API Source: https://docs.heylua.ai/api/integrations Call any connected provider's raw REST API through your agent's integrations ## Overview `Integrations.passthrough` gives your code direct access to a provider's **raw REST API** through the integration your agent is already connected to. The platform relays the call server-side over the agent's own bound connection — your code never sees OAuth tokens or provider credentials, and what the call is allowed to do is exactly what the connection's OAuth grant allows. Connected integrations already expose curated MCP tools to your agent. Passthrough is for everything those tools don't cover: any endpoint the provider documents, with your own query parameters, headers, and JSON bodies. ```typescript theme={null} import { Integrations } from 'lua-cli'; // Any GitHub REST endpoint, through the agent's GitHub connection const res = await Integrations.passthrough('github', { method: 'GET', path: 'repos/acme/app/pulls/42/files', }); if (res.status === 200) { console.log(res.data); // parsed JSON: the PR's files, each with its patch } ``` It works everywhere your code runs — tools, jobs, webhooks, and pre/post processors — and every connected integration also auto-attaches a matching agent tool (see [The auto-attached agent tool](#the-auto-attached-agent-tool) below), so the agent itself can make raw provider calls too. **Scope-gated by the OAuth grant.** The provider enforces its own OAuth scopes on every raw call. A call outside the connection's granted scopes comes back as the provider's own `401`/`403` **inside the response envelope** — never as a thrown error. Reconnect the integration with the needed scopes to widen access. ## Import ```typescript theme={null} import { Integrations } from 'lua-cli'; ``` The wire types are exported too, if you want to name them explicitly: ```typescript theme={null} import type { IntegrationPassthroughRequest, IntegrationPassthroughResponse, IntegrationPassthroughMethod, } from 'lua-cli'; ``` ## Method ### Integrations.passthrough(integrationType, request) Make one raw provider API call through the agent's connected integration. The connected integration to call through, e.g. `'github'`, `'microsoft'`, `'linear'`. Must match an integration the agent is connected to (see [`lua integrations`](/cli/integrations-command)). The provider call to relay — see the fields below. **Returns:** `Promise` — the raw provider response envelope. ### Request fields (IntegrationPassthroughRequest) HTTP method of the provider call. Provider API path **after the provider's base URL**, e.g. `'repos/{owner}/{repo}/pulls/42/files'` for GitHub or `'v1.0/me'` for Microsoft Graph. A leading `/` is tolerated. Relative traversal segments (`..`) are rejected. Query-string parameters (pagination etc.). Forwarded to the provider as-is. A query string embedded in `path` is merged in too. Request body. Objects and arrays are sent as JSON (with `Content-Type: application/json` set for you); a string is sent verbatim — set your own `Content-Type` header for non-JSON payloads. Ignored on `GET`/`HEAD`. Extra request headers forwarded to the provider. `Authorization` and other auth/hop-by-hop headers are managed server-side and cannot be overridden. See the [limitation on `Accept` media-type overrides](#limitation-accept-header-media-type-overrides) below. ### Response envelope (IntegrationPassthroughResponse) | Field | Type | Description | | --------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `number` | The provider's HTTP status code, relayed faithfully — including provider errors like `403` or `404` | | `headers` | `Record` | Provider response headers (lower-cased names; auth-related headers stripped) | | `data` | `unknown` | Provider response body: **parsed JSON** when the provider responded with JSON, the **raw string** otherwise (e.g. `text/html` or `text/plain` round-trips intact) | ## Error model: envelope vs thrown There are two distinct kinds of failure, and they surface differently on purpose: **Provider errors come back in the envelope.** If the provider itself rejects the call — missing OAuth scope (`403`), not found (`404`), provider-side validation (`422`) — the envelope relays the provider's own status and body so you can see exactly what the provider said. **Nothing is thrown.** Always branch on `status`: ```typescript theme={null} const res = await Integrations.passthrough('github', { method: 'GET', path: 'repos/acme/private-repo/pulls/42/files', }); if (res.status === 403) { // The GitHub connection lacks a scope for this call — the body is // GitHub's own error message. Reconnect with wider scopes to fix. return { success: false, error: 'GitHub denied the call', detail: res.data }; } ``` **Route-level failures are thrown.** If the call never reaches the provider, `Integrations.passthrough` throws a plain `Error` with a human-readable message. The relay rejects such calls with one of these typed reasons: | Code | HTTP status | When it fires | Remedy | | ----------------------------- | ----------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `passthrough_invalid_request` | 400 | Missing/invalid `method` or `path` (e.g. a method outside the allowed six, or a path with `..` segments) | Fix the request shape | | `passthrough_disabled` | 403 | An admin has switched passthrough **off** for this integration | Ask a workspace admin to re-enable it, or use the integration's curated tools | | `passthrough_no_connection` | 404 | The agent has no bound connection for `integrationType` | Connect the integration with [`lua integrations connect`](/cli/integrations-command) | | `passthrough_rate_limited` | 429 | The per-agent passthrough rate limit was exceeded | Back off and retry shortly; batch or cache calls | | `passthrough_upstream_error` | 502 | The relay could not reach the upstream integration layer (transport failure) | Transient — retry with backoff | | `passthrough_not_configured` | 503 | The platform's server-side integration relay isn't configured for this workspace | Contact support | The **Code** column is the relay's typed rejection code. The `Error` thrown in sandbox code carries the corresponding human-readable message — the code token itself is not embedded in the message — so treat any throw as "the provider was never called" rather than string-matching on codes. The [auto-attached agent tool](#the-auto-attached-agent-tool) does surface the typed `code` directly on route-level failures. ```typescript theme={null} try { const res = await Integrations.passthrough('github', { method: 'GET', path: 'user' }); return { status: res.status, user: res.data }; } catch (error) { // Route-level only: disabled, no connection, rate limited, transport, … return { success: false, error: error instanceof Error ? error.message : 'passthrough failed' }; } ``` ## Guardrails Passthrough is enabled per integration by default, and workspace admins can switch it off for any integration. A disabled integration rejects with `passthrough_disabled`. Every passthrough call — including denied ones — is audit-logged with identifiers, status, and latency. Request and response bodies are never logged. Calls are rate-limited per agent (default 120 calls per minute). Exceeding it rejects with `passthrough_rate_limited` — back off and retry. ## Limitation: Accept-header media-type overrides Custom request headers are forwarded, but **`Accept` media-type overrides do not change what the provider returns** — the relay normalizes content negotiation. For example, requesting a GitHub pull request with `Accept: application/vnd.github.diff` returns the standard JSON representation, not a unified diff. Use the provider's **JSON-native equivalent** instead: GitHub's `GET repos/{owner}/{repo}/pulls/{n}/files` returns each changed file with its `patch` — the per-file diff — as plain JSON. Non-JSON *response bodies* are unaffected: endpoints that natively return text or HTML (e.g. GitHub's markdown renderer) round-trip intact as strings in `data`. ## Examples ### Microsoft Graph: profile and files The generic Microsoft connector exposes relatively few curated tools — passthrough opens up the whole of Microsoft Graph through it. ```typescript theme={null} import { LuaTool, Integrations } from 'lua-cli'; import { z } from 'zod'; export default class OneDriveRecentTool implements LuaTool { name = 'onedrive_recent'; description = "List the connected user's OneDrive root folder"; inputSchema = z.object({}); async execute() { // Who is connected? const me = await Integrations.passthrough('microsoft', { method: 'GET', path: 'v1.0/me', }); if (me.status !== 200) { return { success: false, status: me.status, error: me.data }; } // List files in the OneDrive root const files = await Integrations.passthrough('microsoft', { method: 'GET', path: 'v1.0/me/drive/root/children', query: { $top: 25, $orderby: 'lastModifiedDateTime desc' }, }); if (files.status !== 200) { return { success: false, status: files.status, error: files.data }; } const items = (files.data as any).value ?? []; return { success: true, user: (me.data as any).displayName, files: items.map((f: any) => ({ name: f.name, modified: f.lastModifiedDateTime })), }; } } ``` ### GitHub: review a pull request List a PR's changed files — each carries its own `patch` (the per-file diff) as JSON — then post a review with a JSON body. ```typescript theme={null} import { LuaTool, Integrations } from 'lua-cli'; import { z } from 'zod'; export default class ReviewPrTool implements LuaTool { name = 'review_pr'; description = 'Read a pull request diff and post a review'; inputSchema = z.object({ owner: z.string(), repo: z.string(), pullNumber: z.number(), comment: z.string(), approve: z.boolean().default(false), }); async execute(input: z.infer) { const base = `repos/${input.owner}/${input.repo}/pulls/${input.pullNumber}`; // 1. The diff, JSON-natively: each file entry includes its `patch` const files = await Integrations.passthrough('github', { method: 'GET', path: `${base}/files`, query: { per_page: 100 }, }); if (files.status !== 200) { // Provider error relayed in the envelope — e.g. 403 on a missing scope return { success: false, status: files.status, error: files.data }; } const patches = (files.data as any[]).map((f) => ({ filename: f.filename, additions: f.additions, deletions: f.deletions, patch: f.patch, // per-file unified diff })); // 2. Post the review (JSON body POSTs through as-is) const review = await Integrations.passthrough('github', { method: 'POST', path: `${base}/reviews`, data: { event: input.approve ? 'APPROVE' : 'COMMENT', body: input.comment, }, }); return { success: review.status === 200, reviewStatus: review.status, filesReviewed: patches.length, patches, }; } } ``` ## The auto-attached agent tool Every connected integration also attaches one synthetic tool to the agent — named `{integrationType}_passthrough`, e.g. `github_passthrough` — alongside that integration's curated tools. It takes an equivalent `method` / `path` / `query` / `body` / `headers` input (note: the tool's body field is named `body`, where the SDK's is `data`) and returns the same `{ status, headers, data }` envelope, so the agent can reach any provider endpoint its connection allows without you writing a tool for it. The same guardrails (admin switch, audit log, rate limit) apply identically. If you'd rather the agent *not* have raw API access to an integration, a workspace admin can turn the integration's passthrough switch off — that disables both the agent tool and `Integrations.passthrough` calls for it. ## Related APIs Connect integrations, manage scopes, and set up triggers Wake the agent when events fire in a connected integration Receive external events with full control of the HTTP response Generate AI responses from within your tools ## See Also * [LuaTool](/api/luatool) - Creating tools * [Jobs API](/api/jobs) - Schedule recurring provider calls * [Environment API](/api/environment) - Configuration for your skill code # Jobs API Source: https://docs.heylua.ai/api/jobs Dynamically create scheduled tasks from within your tools ## Overview The Jobs API allows you to dynamically create scheduled tasks from within your tools. Use this to defer work, schedule reminders, or automate recurring tasks. ```typescript theme={null} import { Jobs } from 'lua-cli'; // Create a one-time reminder const job = await Jobs.create({ name: 'user-reminder', metadata: { message: 'Team meeting in 10 minutes' }, schedule: { type: 'once', executeAt: new Date(Date.now() + 600000) }, execute: async (jobInstance) => { // ✅ Dynamic jobs automatically have user context! const user = await jobInstance.user(); // No userId needed await user.send([{ type: 'text', text: jobInstance.metadata.message }]); } }); ``` **Automatic User Context:** Dynamic jobs created from tools automatically know which user triggered them. Use `jobInstance.user()` to get the user - no userId required! This is different from pre-defined LuaJob which requires `User.get(userId)`. Dynamically create jobs at runtime from within your tools. Pairs with the LuaJob class for pre-defined jobs. ## Import ```typescript theme={null} import { Jobs } from 'lua-cli'; // or import { Jobs } from 'lua-cli/skill'; ``` ## Capabilities Create jobs on-demand from tools Schedule tasks for specific times Set up intervals or cron patterns Jobs automatically know which user triggered them - use `jobInstance.user()` ## Jobs API vs LuaJob Understanding user access in different job types: | Feature | Jobs API (Dynamic) | LuaJob (Pre-defined) | | ------------------ | -------------------- | ----------------------- | | **When Created** | Runtime, from tools | At agent setup | | **User Context** | ✅ Automatic | ❌ None | | **Get User** | `jobInstance.user()` | `User.get(userId)` | | **userId Needed?** | ❌ No (automatic) | ✅ Yes (from metadata) | | **Best For** | User-triggered tasks | Regular scheduled tasks | **Why the difference?** Dynamic jobs are created during a user conversation, so they automatically capture that user's context. Pre-defined jobs run on a schedule with no specific user, so you must explicitly provide a userId if you want to notify someone. ## Methods ### Jobs.create(config) Creates a new scheduled job. Job configuration object **Returns:** `Promise` **Example:** ```typescript theme={null} const job = await Jobs.create({ name: 'reminder-task', description: 'Remind user about meeting', metadata: { message: 'Don\'t forget the meeting!' }, schedule: { type: 'once', executeAt: new Date(Date.now() + 3600000) }, execute: async (jobInstance) => { const user = await jobInstance.user(); await user.send([{ type: 'text', text: jobInstance.metadata.message }]); return { success: true }; } }); ``` ### Jobs.getJob(jobId) Retrieves a job by ID. Job ID to retrieve **Returns:** `Promise` **Example:** ```typescript theme={null} const job = await Jobs.getJob('job_123'); console.log(job.name); console.log(job.activeVersion?.schedule); ``` ### Jobs.getAll(options?) Retrieves all jobs for the current agent. Include dynamically created jobs (default: false) **Returns:** `Promise` **Example:** ```typescript theme={null} // Get all jobs including dynamically created ones const jobs = await Jobs.getAll({ includeDynamic: true }); for (const job of jobs) { console.log(`${job.name}: ${job.data.active ? 'active' : 'inactive'}`); } // Find a specific job by name const trackingJob = jobs.find(j => j.name.startsWith('track-game-')); if (trackingJob) { await trackingJob.deactivate(); } ``` ## Job Configuration ### Required Fields Unique job name When/how often to run the job Function that executes when job runs **Signature:** `(job: JobInstance) => Promise` ### Optional Fields Job description for documentation Data to pass to execute function **Important:** Use metadata to pass data - the execute function cannot access parent scope! Maximum execution time in seconds **Default:** 300 (5 minutes) Retry configuration ```typescript theme={null} retry: { maxAttempts: number; // Max retry attempts backoffSeconds?: number; // Seconds between retries (optional) } ``` Whether to activate immediately **Default:** true ## Schedule Types ### Once (One-time execution) ```typescript theme={null} schedule: { type: 'once', executeAt: Date // When to run } ``` **Examples:** ```typescript theme={null} // Run in 1 hour schedule: { type: 'once', executeAt: new Date(Date.now() + 3600000) } // Run at specific time schedule: { type: 'once', executeAt: new Date('2025-12-25T09:00:00Z') } ``` ### Interval (Recurring at fixed intervals) ```typescript theme={null} schedule: { type: 'interval', seconds: number // Seconds between executions } ``` **Examples:** ```typescript theme={null} // Run every 5 minutes schedule: { type: 'interval', seconds: 300 } // Run every hour schedule: { type: 'interval', seconds: 3600 } ``` ### Cron (Schedule with cron pattern) ```typescript theme={null} schedule: { type: 'cron', expression: string // Cron expression timezone?: string // Optional timezone (e.g., 'America/New_York') } ``` **Examples:** ```typescript theme={null} // Every day at 9 AM schedule: { type: 'cron', expression: '0 9 * * *' } // Every Monday at 8 AM EST schedule: { type: 'cron', expression: '0 8 * * 1', timezone: 'America/New_York' } // Every 15 minutes schedule: { type: 'cron', expression: '*/15 * * * *' } ``` ## Complete Examples ### Reminder Tool ```typescript theme={null} import { LuaTool, Jobs, JobInstance } from 'lua-cli/skill'; import { z } from 'zod'; export default class ReminderTool implements LuaTool { name = 'set_reminder'; description = 'Set a reminder to notify user later'; inputSchema = z.object({ message: z.string().describe('Reminder message'), minutes: z.number().min(1).max(10080).describe('Minutes from now') }); async execute(input: z.infer) { // Create job to notify user later const job = await Jobs.create({ name: `reminder-${Date.now()}`, description: 'User reminder', // ✅ Pass data via metadata (not parent scope!) metadata: { message: input.message, setAt: new Date().toISOString() }, schedule: { type: 'once', executeAt: new Date(Date.now() + input.minutes * 60000) }, execute: async (jobInstance: JobInstance) => { const user = await jobInstance.user(); const message = jobInstance.metadata.message; await user.send([{ type: 'text', text: `⏰ Reminder: ${message}` }]); return { success: true, deliveredAt: new Date().toISOString() }; } }); return { success: true, message: `Reminder set for ${input.minutes} minutes from now`, jobId: job.id, executeAt: job.activeVersion?.schedule?.executeAt }; } } ``` ### Follow-up Tool ```typescript theme={null} import { LuaTool, Jobs, JobInstance, Data } from 'lua-cli/skill'; import { z } from 'zod'; export default class ScheduleFollowupTool implements LuaTool { name = 'schedule_followup'; description = 'Schedule a follow-up message for customer support'; inputSchema = z.object({ ticketId: z.string(), followupHours: z.number().min(1).max(168), message: z.string() }); async execute(input: z.infer) { // Create job for follow-up const job = await Jobs.create({ name: `followup-${input.ticketId}`, description: `Follow-up for ticket ${input.ticketId}`, metadata: { ticketId: input.ticketId, message: input.message }, schedule: { type: 'once', executeAt: new Date(Date.now() + input.followupHours * 3600000) }, execute: async (jobInstance: JobInstance) => { const user = await jobInstance.user(); const { ticketId, message } = jobInstance.metadata; // Check if ticket is still open const tickets = await Data.search('tickets', ticketId, 1); if (tickets.length > 0 && tickets[0].status === 'open') { // Send follow-up await user.send([{ type: 'text', text: `📋 Ticket #${ticketId} Follow-up:\n\n${message}` }]); // Update ticket await Data.update('tickets', tickets[0].id, { ...tickets[0].data, followupSent: true, followupAt: new Date().toISOString() }); return { success: true, sent: true }; } return { success: true, sent: false, reason: 'Ticket closed' }; } }); return { success: true, message: `Follow-up scheduled for ${input.followupHours} hours`, jobId: job.id }; } } ``` ### Recurring Report ```typescript theme={null} import { LuaTool, Jobs, JobInstance, Products } from 'lua-cli/skill'; import { z } from 'zod'; export default class DailyReportTool implements LuaTool { name = 'setup_daily_report'; description = 'Set up daily sales report'; inputSchema = z.object({ timeOfDay: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) .describe('Time in HH:MM format (24-hour)') }); async execute(input: z.infer) { const [hour, minute] = input.timeOfDay.split(':'); const job = await Jobs.create({ name: 'daily-sales-report', description: 'Daily sales summary', metadata: { reportTime: input.timeOfDay }, schedule: { type: 'cron', expression: `${minute} ${hour} * * *` // Every day at specified time }, execute: async (jobInstance: JobInstance) => { // Get products sold today const products = await Products.get(1, 100); const totalValue = products.reduce((sum, p) => sum + (p.price || 0), 0); const report = `📊 Daily Sales Report\n\n` + `Total Products: ${products.length}\n` + `Catalog Value: $${totalValue.toFixed(2)}\n` + `Report Time: ${jobInstance.metadata.reportTime}`; const user = await jobInstance.user(); await user.send([{ type: 'text', text: report }]); return { success: true, productCount: products.length, totalValue }; } }); return { success: true, message: `Daily report scheduled for ${input.timeOfDay}`, jobId: job.id }; } } ``` ## Important: Metadata Pattern **Jobs execute functions must be self-contained!** They cannot access parent scope variables. ```typescript theme={null} // ❌ WRONG - Accessing parent scope async execute(input: any) { const userMessage = input.message; // This variable... await Jobs.create({ execute: async (job) => { // ...is NOT available here! ❌ await user.send(userMessage); // Error: userMessage is not defined } }); } // ✅ CORRECT - Using metadata async execute(input: any) { await Jobs.create({ metadata: { message: input.message // ✅ Pass via metadata }, execute: async (job) => { const message = job.metadata.message; // ✅ Access from metadata const user = await job.user(); await user.send([{ type: 'text', text: message }]); } }); } ``` **Why?** Jobs are serialized, bundled, and executed in an isolated sandbox. They can't access the parent function's scope. ## JobInstance Methods The `JobInstance` passed to execute functions provides: ### Properties | Property | Type | Description | | --------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `id` | `string` | Unique job identifier | | `name` | `string` | Job name | | `activeVersion` | `JobVersion` | The active version with schedule, timeout, etc. | | `metadata` | `object` | Job metadata | | `data` | `Job` | Full job data including all versions | | `execution` | `{ executionId, attempt, occurrenceId, scheduledTime? }` | Runtime execution metadata for the current run (see [Delivery Semantics](#delivery-semantics)). Undefined in local runs. | ### jobInstance.user() Gets the user who triggered the job. **Returns:** `Promise` **Automatic User Context:** This method is ONLY available for dynamic jobs created via `Jobs.create()`. The user context is automatically captured when the job is created from a tool. Pre-defined LuaJob must use `User.get(userId)` instead. **Example:** ```typescript theme={null} // In a tool creating a dynamic job await Jobs.create({ execute: async (jobInstance) => { // ✅ Works! User context automatically available const user = await jobInstance.user(); await user.send([{ type: 'text', text: 'Job complete!' }]); } }); ``` **Comparison with LuaJob:** ```typescript theme={null} // ❌ This won't work in pre-defined LuaJob const job = new LuaJob({ execute: async (job) => { const user = await job.user(); // ❌ No user() method! } }); // ✅ Use this for LuaJob instead const job = new LuaJob({ metadata: { userId: 'user_abc123' }, execute: async (job) => { const user = await User.get(job.metadata.userId); // ✅ Works! } }); ``` ### job.metadata Access to the metadata passed during creation. **Example:** ```typescript theme={null} execute: async (job) => { console.log(job.metadata.customData); } ``` ### job.updateMetadata(data) Updates job metadata. **Example:** ```typescript theme={null} execute: async (job) => { await job.updateMetadata({ lastRun: new Date().toISOString(), runCount: (job.metadata.runCount || 0) + 1 }); } ``` ### job.trigger(versionId?) Manually triggers the job execution (ignores schedule). Uses the active version by default. **Parameters:** * `versionId` (optional): Specific version to execute. Defaults to `activeVersion`. **Returns:** `Promise` **Example:** ```typescript theme={null} // Trigger with default active version const execution = await job.trigger(); console.log('Execution ID:', execution.id); console.log('Status:', execution.status); // Trigger with specific version const execution = await job.trigger('version_abc123'); ``` ### job.delete() Deletes the job (or deactivates if it has versions). **Example:** ```typescript theme={null} execute: async (job) => { // Do work... // Delete one-time job after execution await job.delete(); } ``` ### job.activate() Activates the job, enabling it to run on schedule. **Returns:** `Promise` **Example:** ```typescript theme={null} // Re-enable a paused job const job = await Jobs.getJob('job_123'); await job.activate(); console.log('Job is now active'); ``` ### job.deactivate() Deactivates the job, preventing it from running on schedule. Useful for jobs that should stop themselves. **Returns:** `Promise` **Example:** ```typescript theme={null} // Job stops itself when work is complete execute: async (job) => { const data = await fetchData(); if (data.isComplete) { // Stop the recurring job await job.deactivate(); return { action: 'completed', stopped: true }; } return { action: 'processed', data }; } ``` ## Retry Configuration ```typescript theme={null} await Jobs.create({ name: 'important-task', retry: { maxAttempts: 3, backoffSeconds: 60 // Wait 60s between retries }, execute: async (job) => { // If this throws, will retry up to 3 times await criticalOperation(); } }); ``` ## Delivery Semantics Jobs run **at least once**. Each scheduled occurrence is durably queued and executed on isolated infrastructure, and a failed attempt is retried according to your [retry configuration](#retry-configuration). A job may therefore run **more than once** for a single scheduled occurrence, so keep side effects idempotent — guard them with your own state (for example, a flag in `metadata` or a record in [Data](/api/data)). * Retries happen **only when an attempt fails**, using your `maxAttempts` and `backoffSeconds`. * The platform caps attempts at **10** regardless of config — a higher `maxAttempts` is clamped to 10. Each execution receives metadata on `job.execution`: | Field | Description | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `occurrenceId` | Stable identifier for the logical occurrence — **the same value across every retry attempt**. Use it as your idempotency key so side effects run once per occurrence, no matter how many times it retries. | | `executionId` | Identifier for this individual attempt. **Changes on every retry** — do not use it for idempotency. | | `attempt` | 1-based attempt counter (`1` on the first run, `2` on the first retry, and so on). | | `scheduledTime` | For scheduled runs, the ISO-8601 fire time of the occurrence (a schedule-slot key). Absent for manually triggered runs. | ```typescript theme={null} execute: async (job) => { const { occurrenceId, executionId, attempt } = job.execution ?? {}; // Re-check your own state before repeating side effects if (attempt > 1) { console.log(`Retry ${attempt} of occurrence ${occurrenceId} (attempt id ${executionId})`); } // ... } ``` `job.execution` is only present when a job runs on Lua's infrastructure. It is `undefined` during local `lua test` / `lua dev` runs, which execute in-process — so always read it optionally (`job.execution?.occurrenceId`). ### Idempotency Because a job runs **at least once**, design your side effects to be safe to repeat. The `occurrenceId` is stable across all retries of one occurrence, so it makes a natural idempotency key: record it after the side effect succeeds, and skip the work if you've already processed it. Attempts of one occurrence never run concurrently, so a simple check-then-write guard like the one below is safe. ```typescript theme={null} execute: async (job) => { const occurrenceId = job.execution?.occurrenceId; // Skip if this occurrence's side effect already ran on an earlier attempt if (occurrenceId) { const done = await Data.get('processed_occurrences', { occurrenceId }); if (done.data.length > 0) return; } await chargeCustomer(); // the side effect that must happen exactly once // Mark it processed so a later retry is a no-op if (occurrenceId) { await Data.create('processed_occurrences', { occurrenceId }); } } ``` Prefer `occurrenceId` over `executionId` for deduplication: `executionId` is re-minted on every retry, so keying on it would let each attempt's side effect through. For scheduled jobs you can instead key on `scheduledTime` when you want one run per schedule slot. ## Timeout Configuration ```typescript theme={null} await Jobs.create({ name: 'long-running-task', timeout: 300, // 5 minutes (seconds; supported range: 1-600) execute: async (job) => { // Will be terminated if exceeds 5 minutes await longOperation(); } }); ``` ## Best Practices Give each job a descriptive, unique name ```typescript theme={null} name: `reminder-${Date.now()}` name: `followup-ticket-${ticketId}` ``` Always use metadata to pass data to execute function ```typescript theme={null} metadata: { userId: input.userId, message: input.message, timestamp: new Date().toISOString() } ``` Implement error handling in execute function ```typescript theme={null} execute: async (job) => { try { await doWork(); return { success: true }; } catch (error) { return { success: false, error: error.message }; } } ``` Execute functions are isolated - use metadata! ```typescript theme={null} // ❌ This won't work const data = input.value; execute: async (job) => { console.log(data); // Error! } // ✅ Use metadata instead metadata: { data: input.value }, execute: async (job) => { console.log(job.metadata.data); // Works! } ``` If a job isn't behaving as expected, add `console.log` statements and check execution logs: ```typescript theme={null} execute: async (jobInstance) => { const data = await Data.get('orders', { status: 'pending' }); console.log(`[Job] Found ${data.data.length} pending orders`); // ... rest of job } ``` Then check: `lua logs --type job --name my-job-name --limit 10`. See the [Debugging Skills guide](/cli/debugging) for the full workflow. ## Related APIs Pre-defined scheduled jobs Send messages from jobs Store and retrieve data Agent configuration Inspect runtime return values ## See Also * [LuaJob Class](/api/luajob) - Pre-defined jobs * [Tool Examples](/examples/overview) * [Concepts: Workflows](/concepts/workflows) # Lua API Source: https://docs.heylua.ai/api/lua Access request-level runtime information in your code ## Overview The `Lua` API provides access to request-level runtime information. Use it in your tools, tool conditions, preprocessors, and postprocessors to access context about the current request. ```typescript theme={null} import { Lua } from 'lua-cli'; // Access the current channel const channel = Lua.request.channel; if (channel === 'whatsapp') { // WhatsApp-specific logic } ``` ## Import ```typescript theme={null} import { Lua, Channel } from 'lua-cli'; ``` ## Availability The `Lua` API is available in: | Context | Available | | --------------------------- | --------- | | **Tool `execute`** | ✅ Yes | | **Tool `condition`** | ✅ Yes | | **Skill `condition`** | ✅ Yes | | **Preprocessor `execute`** | ✅ Yes | | **Postprocessor `execute`** | ✅ Yes | ## Lua.request The `request` object contains information about the current request context. ### channel The channel through which the current request originated. ```typescript theme={null} Lua.request.channel: Channel ``` **Type:** `Channel` ```typescript theme={null} type Channel = | 'web' // Chat widget on a website | 'whatsapp' // WhatsApp integration | 'facebook' // Facebook Messenger integration | 'instagram' // Instagram integration | 'slack' // Slack integration | 'teams' // Microsoft Teams integration | 'front' // Front integration | 'messagebird' // MessageBird integration | 'api' // Direct HTTP API call | 'dev' // Local development / CLI testing | 'email' // Email integration | string; // Any other channel ``` **Example - Channel-specific behavior:** ```typescript theme={null} import { Lua } from 'lua-cli'; const channel = Lua.request.channel; switch (channel) { case 'whatsapp': // Keep responses concise for mobile return { format: 'brief' }; case 'web': // Can use rich formatting return { format: 'rich' }; case 'api': // Return structured data return { format: 'json' }; default: return { format: 'standard' }; } ``` ### webhook The webhook object contains information about the incoming webhook request from channel integrations. This provides access to the original data sent by the channel provider (WhatsApp, Slack, Teams, etc.). ```typescript theme={null} Lua.request.webhook: { payload: any } | undefined ``` **Type:** `{ payload: any } | undefined` The webhook object is only available when the request originated from a webhook-based channel. For direct API calls or the web chat widget, this will be `undefined`. #### webhook.payload The raw, unmodified webhook payload from the channel provider. **Available for channels:** * `whatsapp` - Full WhatsApp Cloud API webhook payload * `slack` - Slack Events API payload * `teams` - Microsoft Teams activity object * `front` - Front webhook body * `facebook` - Facebook Messenger webhook event * `instagram` - Instagram Messaging webhook event * `messagebird` - MessageBird webhook body * `email` - JMAP-aligned parsed email (headers + envelope; not raw MIME — see note below) **Example - Accessing WhatsApp webhook data:** ```typescript theme={null} import { Lua } from 'lua-cli'; const webhook = Lua.request.webhook; if (Lua.request.channel === 'whatsapp' && webhook) { // Access WhatsApp-specific data const entry = webhook.payload.entry?.[0]; const value = entry?.changes?.[0]?.value; const messageId = value?.messages?.[0]?.id; const phoneNumberId = value?.metadata?.phone_number_id; return { messageId, phoneNumberId, raw: webhook.payload, }; } ``` **Example - Slack event data:** ```typescript theme={null} import { Lua } from 'lua-cli'; const webhook = Lua.request.webhook; if (Lua.request.channel === 'slack' && webhook) { const event = webhook.payload.event; const teamId = webhook.payload.team_id; const channelId = event?.channel; return { teamId, channelId, eventType: event?.type, }; } ``` **Example - Teams activity:** ```typescript theme={null} import { Lua } from 'lua-cli'; const webhook = Lua.request.webhook; if (Lua.request.channel === 'teams' && webhook) { const activity = webhook.payload; const conversationId = activity.conversation?.id; const tenantId = activity.conversation?.tenantId; const serviceUrl = activity.serviceUrl; return { conversationId, tenantId, serviceUrl, }; } ``` **Example - Email metadata:** ```typescript theme={null} import { Lua } from 'lua-cli'; const webhook = Lua.request.webhook; if (Lua.request.channel === 'email' && webhook) { const { messageId, // bare ID, no inReplyTo, // bare ID, or null references, // array of bare IDs subject, from, // [{ address, name }] to, cc, date, // ISO 8601 string headerLines, // [{ key, line }] — full ordered RFC 5322 header list } = webhook.payload; const isReply = !!inReplyTo; const threadRoot = references?.[0] ?? messageId; return { messageId, isReply, threadRoot, subject }; } ``` The `email` channel's payload differs from JSON-over-HTTP channels (WhatsApp, Slack, etc.). Email arrives over SMTP as RFC 5322 MIME — there is no JSON envelope from a provider to forward. Lua parses the message and exposes it as a [JMAP](https://datatracker.ietf.org/doc/html/rfc8621#section-4)-aligned object: typed common headers plus a `headerLines` array preserving the full RFC 5322 header order. If your email channel is backed by an AgentMail inbox, `webhook.payload` follows AgentMail's native event shape (`message_id`, `thread_id`, `inbox_id`, …) rather than the JMAP shape above. Branch on the presence of `messageId` (Lua-native) vs `message_id` (AgentMail) until the shapes are normalized. ## Complete Examples ### Conditional Tool Availability Make a tool available only on certain channels: ```typescript theme={null} import { LuaTool, Lua } from 'lua-cli'; import { z } from 'zod'; export default class WhatsAppOnlyTool implements LuaTool { name = 'send_whatsapp_template'; description = 'Send a WhatsApp message template'; inputSchema = z.object({ templateId: z.string(), }); // Tool only shows up on WhatsApp condition = async () => { return Lua.request.channel === 'whatsapp'; }; async execute(input: z.infer) { // Implementation return { success: true }; } } ``` ### Channel-Aware Tool Logic Adjust tool behavior based on channel: ```typescript theme={null} import { LuaTool, Lua } from 'lua-cli'; import { z } from 'zod'; export default class GetSupportInfoTool implements LuaTool { name = 'get_support_info'; description = 'Get customer support information'; inputSchema = z.object({}); async execute() { const channel = Lua.request.channel; const baseInfo = { email: 'support@example.com', hours: '9 AM - 5 PM EST', }; // Add channel-specific info switch (channel) { case 'whatsapp': return { ...baseInfo, message: 'You can also reply here for quick support!', }; case 'web': return { ...baseInfo, phone: '1-800-SUPPORT', liveChatUrl: 'https://example.com/chat', }; default: return baseInfo; } } } ``` ### Preprocessor with Channel Context Use channel in preprocessors: ```typescript theme={null} import { PreProcessor, Lua } from 'lua-cli'; const channelGreeting = new PreProcessor({ name: 'channel-greeting', description: 'Add channel-specific greeting context', priority: 1, execute: async (user, messages, channel) => { const currentChannel = Lua.request.channel; return { action: 'proceed' }; }, }); ``` ### Postprocessor with Channel-Specific Formatting Format responses based on channel: ```typescript theme={null} import { PostProcessor, Lua } from 'lua-cli'; const formatForChannel = new PostProcessor({ name: 'format-for-channel', description: 'Format response based on channel', execute: async (user, message, response, channel) => { const currentChannel = Lua.request.channel; // WhatsApp has character limits - truncate long responses if (currentChannel === 'whatsapp' && response.length > 4096) { return { modifiedResponse: response.substring(0, 4093) + '...', }; } return { modifiedResponse: response }; }, }); ``` ## Future Expansion The `Lua` API will be expanded to include additional runtime information, such as: * User profile data * Session information * More request metadata This API is designed to grow. Check back for new properties as they become available. ## Best Practices Always import the `Lua` API from `lua-cli`: ```typescript theme={null} import { Lua } from 'lua-cli'; const channel = Lua.request.channel; ``` The channel type includes `unknown` for unrecognized values: ```typescript theme={null} const channel = Lua.request.channel; if (channel === 'whatsapp' || channel === 'web') { // Known channel handling } else { // Fallback for unknown channels } ``` Use channel for minor adjustments, not completely different flows: ```typescript theme={null} // ✅ Good - Minor adjustments const maxLength = channel === 'whatsapp' ? 4096 : 10000; // ❌ Avoid - Completely different tools per channel // Instead, use tool conditions to show/hide tools ``` For channel-specific tools, use the `condition` function: ```typescript theme={null} condition = async () => { return Lua.request.channel === 'whatsapp'; }; ``` `LuaSkill` accepts the same `condition` to gate a whole capability at once — see [Conditional Skills](/api/luaskill#conditional-skills). Always check if webhook exists before accessing it: ```typescript theme={null} const webhook = Lua.request.webhook; if (webhook) { // Safe to access webhook.payload properties const data = webhook.payload.entry?.[0]?.changes?.[0]?.value; } ``` For channel-specific webhook handling, always check both: ```typescript theme={null} if (Lua.request.channel === 'whatsapp' && Lua.request.webhook) { // WhatsApp-specific webhook handling const payload = Lua.request.webhook.payload; } ``` ## Related Learn about creating tools with conditions Intercept and modify incoming messages Transform agent responses Overview of available channels # LuaAgent Source: https://docs.heylua.ai/api/luaagent Unified agent configuration combining skills, webhooks, jobs, and processors ## Overview `LuaAgent` is the recommended way to configure your AI agent in modern lua-cli. It provides a single, intuitive configuration object that combines skills, webhooks, jobs, and message processors. ```typescript theme={null} import { LuaAgent } from 'lua-cli'; export const agent = new LuaAgent({ name: 'my-agent', persona: 'You are a helpful assistant...', skills: [skill1, skill2] }); ``` LuaAgent replaces the need to export individual skills, webhooks, and jobs separately. ## Why LuaAgent? All agent components in one configuration object CLI auto-manages `lua.skill.yaml` (do not edit manually) Improved autocomplete and type safety Organized, readable agent configuration ## Constructor ### new LuaAgent(config) Creates a new agent configuration. Agent configuration object ## Configuration Parameters ### Required Fields Agent identifier used for organization and logging **Examples**: `'customer-support-agent'`, `'sales-assistant'` Defines the agent's personality, behavior, tone, and capabilities **String form (recommended):** * Who the agent is * What role they play * How they should communicate * What they can and cannot do * Any specific behaviors or rules **Object form (channel-aware):** Only use when you need measurably different behavior on voice vs text channels. * `base` — Always rendered, on every channel * `voice` — Appended to `base` on voice channels, ignored on text * `text` — Appended to `base` on text channels (web, WhatsApp, SMS), ignored on voice See [Channel-aware Prompts](/concepts/channel-aware-prompts) for details. ### Optional Fields Short capability summary used by [Spaces](/overview/spaces) to decide when to delegate to this agent One or two sentences about **what the agent can do** — around 200 characters. The persona still owns voice, tone and detailed instructions; this is only the routing signal a supervisor reads when choosing between member agents. **Example**: `'Handles refunds, returns and order cancellations for existing orders.'` Ownership is opt-in. Omit it and any description set in the admin dashboard is left untouched. Set it to an empty string to clear it. **Default**: unset — the platform falls back to deriving a summary from the persona Array of skills (tool collections) the agent can use **Default**: `[]` HTTP endpoints that can receive external events **Default**: `[]` Scheduled tasks that run automatically **Default**: `[]` Functions that process messages before they reach the agent **Default**: `[]` Functions that process agent responses before sending to users **Default**: `[]` MCP (Model Context Protocol) servers providing external tools **Default**: `[]` **See**: [LuaMCPServer API](/api/luamcpserver) The LLM your agent uses. Either a static `'provider/model'` string or a resolver function that selects the model dynamically per request. **Format**: `'provider/model'` — e.g. `'google/gemini-2.5-flash'`, `'openai/gpt-4o'` **Default**: `'google/gemini-2.5-flash'` Lua manages the API credentials — you don't need to configure any API keys. Support for user-provided API keys is coming in a future release. **Static model:** ```typescript theme={null} model: 'openai/gpt-4o' ``` **Dynamic resolver** — receives the full request with all platform APIs available (`User`, `Baskets`, `Products`, etc.): ```typescript theme={null} model: async (request) => { const user = await User.get(); return user.data?.isPremium ? 'openai/gpt-4o' : 'google/gemini-2.5-flash'; } ``` **See**: [Model Selection](/overview/model-selection) Per-call sampling settings forwarded to the model on every chat turn. Set them once on the agent instead of overriding them in every skill via `AI.generate({ temperature })`. Undefined leaves provider defaults in place. ```typescript theme={null} import { LuaAgent, type AgentModelSettings } from 'lua-cli'; export const agent = new LuaAgent({ name: 'role-scorer', persona: '...', modelSettings: { temperature: 0.2, maxOutputTokens: 4096, }, skills: [scoreRoleSkill], }); ``` **Supported fields:** | Field | Type | Range / Notes | | ------------------ | -------------------- | ---------------------------------------------------------------- | | `temperature` | `number` | `0`–`2` (provider-dependent; typical for OpenAI / Anthropic) | | `topP` | `number` | `0`–`1`. Set either `temperature` or `topP`, not both. | | `topK` | `number` | Top-K sampling (advanced) | | `maxOutputTokens` | `number` | Maximum tokens to generate | | `presencePenalty` | `number` | `-2`–`2` (OpenAI; not all providers support) | | `frequencyPenalty` | `number` | `-2`–`2` (OpenAI; not all providers support) | | `stopSequences` | `string[]` | Generation halts when the model emits one | | `seed` | `number` | Random seed for deterministic sampling (provider support varies) | | `reasoning` | `{ effort?, show? }` | Default reasoning effort for this agent. See below. | Obviously-broken values (non-finite numbers, `temperature` outside `0..2`, `topP` outside `0..1`, non-positive `maxOutputTokens`, non-string `stopSequences`, an unrecognized `reasoning.effort`, or a non-boolean `reasoning.show`) are rejected at construction time. Provider-specific range checks are deferred to the provider. **`reasoning` — per-agent default reasoning effort** Unlike the other fields, `reasoning` isn't a raw sampling parameter — it's a normalized setting Lua translates into each provider's own reasoning/thinking dialect, so the same config works whether the resolved model is Claude, GPT, Gemini, or another reasoning-capable model. ```typescript theme={null} export const agent = new LuaAgent({ name: 'support-triage', persona: '...', modelSettings: { reasoning: { effort: 'low', show: false }, }, skills: [triageSkill], }); ``` | Sub-field | Type | Notes | | --------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `effort` | `'off' \| 'minimal' \| 'low' \| 'medium' \| 'high' \| 'max'` | How much the model reasons before responding. `'off'` disables reasoning where the provider allows it. Any of the 6 values is safe to send for any resolved model — Lua clamps it to that provider's nearest supported tier rather than erroring (e.g. a model with no "off" state falls back to its lowest supported tier instead). | | `show` | `boolean` | Whether the model's reasoning trace is surfaced to the caller. Default `true`. | This is a **default for the agent** — a per-request `reasoning` override on the chat request always takes precedence over it, field by field (setting only `effort` on a request doesn't clear an agent-level `show: false`). If neither the request nor the agent sets an `effort`, Lua's platform default applies: **adaptive reasoning where the resolved model supports it** (Claude's newest generations, Gemini 2.5's dynamic thinking budget), and an **explicit low effort otherwise** — favoring lower cost and latency on turns that don't ask for deeper thinking. A few models deviate from that low default: GPT's top-tier reasoning variant has a medium floor (it doesn't accept anything lower), DeepSeek's reasoning model has no tier below high, and Qwen's reasoning is on/off only (no effort tiers). `reasoning` only affects reasoning behavior — it is not forwarded as a raw `modelSettings` field to the underlying call, so it never collides with provider-specific sampling parameters. ## Basic Example ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; import GetWeatherTool from './tools/GetWeatherTool'; const weatherSkill = new LuaSkill({ name: 'weather-skill', description: 'Weather information', context: 'Use these tools to get weather data', tools: [new GetWeatherTool()] }); export const agent = new LuaAgent({ name: 'weather-assistant', persona: 'You are a friendly weather assistant. Provide weather information when asked.', skills: [weatherSkill] }); ``` ## Complete Example ```typescript theme={null} import { LuaAgent, LuaSkill, LuaWebhook, LuaJob, LuaMCPServer, PreProcessor, PostProcessor } from 'lua-cli'; // Import your components import { customerSupportSkill } from './skills/customer-support'; import { productSkill } from './skills/products'; import { orderSkill } from './skills/orders'; import paymentWebhook from './webhooks/payment'; import orderWebhook from './webhooks/order'; import dailyReportJob from './jobs/daily-report'; import cleanupJob from './jobs/cleanup'; import profanityFilter from './preprocessors/profanity-filter'; import addDisclaimer from './postprocessors/disclaimer'; import filesystemServer from './mcp/filesystem'; export const agent = new LuaAgent({ name: 'ecommerce-assistant', description: 'Helps shoppers find products, manage carts and orders, and process returns for Acme Store.', persona: `You are Emma, a helpful e-commerce assistant for Acme Store. Your role: - Help customers find products - Assist with orders and cart management - Answer product questions - Process returns and exchanges Communication style: - Friendly and professional - Patient and understanding - Proactive with suggestions - Clear and concise Capabilities: - Search product catalog - Manage shopping carts - Track orders - Process payments - Answer product questions Limitations: - Cannot ship orders manually - Cannot modify pricing (suggest contacting manager) - Cannot issue refunds over $100 (requires manager approval) `, skills: [ customerSupportSkill, productSkill, orderSkill ], webhooks: [ paymentWebhook, orderWebhook ], jobs: [ dailyReportJob, cleanupJob ], preProcessors: [ profanityFilter ], postProcessors: [ addDisclaimer ], mcpServers: [ filesystemServer ] }); ``` ## Persona Best Practices ### ✅ Good Persona ```typescript theme={null} persona: `You are Dr. Smith, a medical information assistant. Role: Provide general health information and wellness tips. Guidelines: - Always clarify you're not a licensed doctor - Recommend seeing professionals for serious concerns - Use clear, non-technical language - Be empathetic and supportive Tone: Professional, caring, informative` ``` ### ❌ Bad Persona ```typescript theme={null} // Too vague persona: `You are helpful` // Too robotic persona: `I am an AI assistant that answers questions.` // Missing boundaries persona: `You can do anything the user asks.` ``` ## Persona Storage Persona is stored in your `LuaAgent` code definition (in `src/index.ts`). The `lua.skill.yaml` file is state-only and tracks IDs and versions, not persona content. When you edit persona using `lua persona`, it updates the persona field in your `LuaAgent` code directly. ## Multiple Skills Example ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; // Organize tools by domain const weatherSkill = new LuaSkill({ name: 'weather-skill', description: 'Weather tools', context: 'Use for weather queries', tools: [getWeatherTool, getForecastTool] }); const calculatorSkill = new LuaSkill({ name: 'calculator-skill', description: 'Math tools', context: 'Use for calculations', tools: [addTool, multiplyTool, advancedMathTool] }); const dataSkill = new LuaSkill({ name: 'data-skill', description: 'Data management', context: 'Use for storing and retrieving user data', tools: [saveDataTool, queryDataTool] }); export const agent = new LuaAgent({ name: 'multi-purpose-assistant', persona: 'You are a versatile assistant that helps with weather, calculations, and data management.', skills: [weatherSkill, calculatorSkill, dataSkill] }); ``` ## With All Components ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import { skills } from './skills'; import { webhooks } from './webhooks'; import { jobs } from './jobs'; import { preProcessors } from './preprocessors'; import { postProcessors } from './postprocessors'; import { mcpServers } from './mcp'; export const agent = new LuaAgent({ name: 'enterprise-assistant', persona: `You are an enterprise-grade AI assistant. - Professional and efficient - Data-driven and accurate - Proactive with insights - Security and privacy conscious`, skills: skills, webhooks: webhooks, jobs: jobs, preProcessors: preProcessors, postProcessors: postProcessors, mcpServers: mcpServers }); ``` ## Dynamic Configuration ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import { env } from 'lua-cli'; // Load configuration from environment const agentName = env('AGENT_NAME') || 'default-agent'; const customPersona = env('AGENT_PERSONA') || 'You are a helpful assistant'; // Conditionally add components const skills = [coreSkill]; if (env('ENABLE_ANALYTICS') === 'true') { skills.push(analyticsSkill); } export const agent = new LuaAgent({ name: agentName, persona: customPersona, skills: skills, // Add webhooks only in production webhooks: env('NODE_ENV') === 'production' ? productionWebhooks : [] }); ``` ## Migration from v2.x **Before (v2.x):** ```typescript theme={null} // Multiple exports export const skill1 = new LuaSkill({...}); export const skill2 = new LuaSkill({...}); export const webhook1 = new LuaWebhook({...}); ``` **After:** ```typescript theme={null} // Single export with LuaAgent export const agent = new LuaAgent({ name: 'my-agent', persona: '...', skills: [skill1, skill2], webhooks: [webhook1] }); ``` ## Related APIs Tool collections Individual tools HTTP endpoints Scheduled tasks External MCP tools ## See Also * [Quick Start Guide](/getting-started/quick-start) * [First Skill Tutorial](/getting-started/first-skill) * [Skills and Tools Concept](/concepts/skills-and-tools) # DeviceClient API Source: https://docs.heylua.ai/api/luadeviceclient Complete API reference for the @lua-ai-global/device-client package ## DeviceClient The main class for connecting a device to a Lua AI agent. Extends `EventEmitter`. ```typescript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const client = new DeviceClient(config: DeviceClientConfig); ``` ### Constructor Configuration object for the device client. See [DeviceClientConfig](#deviceclientconfig) below. ## DeviceClientConfig At least one credential field, `deviceCredential` or `apiKey`, is required. Agent ID to connect to. Device credential for new provisioning. See [Device credentials](/devices/credentials). Existing compatibility field, supported indefinitely. Non-dotted legacy keys also remain valid indefinitely. Unique name for this device. Lowercase with hyphens. Commands this device supports. Sent to the server at connect time. Transport protocol. Server URL for Socket.IO transport. MQTT broker URL. Required when `transport` is `'mqtt'`. CDN URL for file uploads and downloads. Optional device group name. Provide either credential field. If you provide both, their values must match. ## Methods ### connect() Connect to the device gateway. Resolves when the connection is established and authenticated. Automatically reconnects on disconnect unless `disconnect()` was called. ```typescript theme={null} await client.connect(): Promise ``` ### disconnect() Disconnect from the device gateway. Stops auto-reconnection. ```typescript theme={null} await client.disconnect(): Promise ``` ### isConnected() Check if the client is currently connected. ```typescript theme={null} client.isConnected(): boolean ``` ### onCommand() Register a handler for a specific command name. The handler receives the command payload and must return a result (or throw an error). ```typescript theme={null} client.onCommand(name: string, handler: CommandHandler): void ``` Command name to handle. Async function that executes the command. Return value is sent back to the agent. Thrown errors are sent as error responses. ```typescript theme={null} client.onCommand('read_temperature', async (payload) => { return { temperature: 22.5, unit: 'celsius' }; }); ``` ### trigger() Fire a trigger event to the agent. Resolves when the server acknowledges receipt (not execution completion). ```typescript theme={null} await client.trigger(name: string, payload: any): Promise ``` Trigger name. Trigger payload data. ```typescript theme={null} await client.trigger('temperature_alert', { temperature: 42.1, threshold: 40, }); ``` ### onTriggerResult() Listen for trigger execution results from the agent. Optional -- triggers are fire-and-forget by default. ```typescript theme={null} client.onTriggerResult(name: string, handler: TriggerResultHandler): void ``` Trigger name to listen for. Callback function receiving the trigger execution result. ## Properties ### cdn CDN client for uploading and downloading files. Available immediately after construction. ```typescript theme={null} client.cdn: CDN ``` ## CDN The CDN class provides file upload and download capabilities. ### cdn.upload() Upload a file to the Lua CDN. ```typescript theme={null} await client.cdn.upload( data: Buffer | Blob, filename: string, contentType?: string ): Promise ``` File content. Filename with extension. MIME type. Defaults to `application/octet-stream`. **Returns:** `CdnUploadResult` ```typescript theme={null} interface CdnUploadResult { fileId: string; mediaType: string; extension: string; url: string; } ``` ### cdn.download() Download a file from the CDN. ```typescript theme={null} await client.cdn.download(fileId: string): Promise ``` ### cdn.getUrl() Get the public URL for a file. ```typescript theme={null} client.cdn.getUrl(fileId: string): string ``` ## Events The `DeviceClient` extends `EventEmitter` and emits the following events: | Event | Payload | Description | | --------------- | ------------------------- | ------------------------------ | | `connected` | *(none)* | Initial connection established | | `reconnected` | *(none)* | Reconnected after a disconnect | | `disconnected` | `reason: string` | Connection lost | | `error` | `error: any` | Connection or protocol error | | `trigger_ack` | `DeviceTriggerAckMessage` | Server acknowledged a trigger | | `trigger_error` | `error: any` | Trigger delivery error | ```typescript theme={null} client.on('connected', () => console.log('Online')); client.on('disconnected', (reason) => console.log('Offline:', reason)); client.on('error', (err) => console.error('Error:', err)); ``` ## DeviceCommandDefinition Describes a command the device supports. Command name. Human-readable description shown to the AI agent. JSON Schema for input parameters. Command timeout in milliseconds. Retry configuration for failed commands. ## Type Exports The package exports the following types: ```typescript theme={null} import type { DeviceClientConfig, DeviceCommandDefinition, DeviceCommandMessage, DeviceResponseMessage, DeviceTriggerAckMessage, CommandHandler, TriggerResultHandler, } from '@lua-ai-global/device-client'; import type { CdnUploadResult } from '@lua-ai-global/device-client'; ``` ## Next Steps Usage guide with examples API reference for the MicroPython client Get started in 5 minutes Full working examples # LuaJob Source: https://docs.heylua.ai/api/luajob Pre-defined scheduled tasks that run automatically ## Overview `LuaJob` allows you to define scheduled tasks that run automatically without user interaction. Unlike the Jobs API which creates jobs dynamically, LuaJob is for pre-defined jobs that are part of your agent configuration. ```typescript theme={null} import { LuaJob, User } from 'lua-cli'; const dailyReport = new LuaJob({ name: 'daily-sales-report', description: 'Generate daily sales summary', metadata: { userId: 'user_abc123' // Store user ID to notify }, schedule: { type: 'cron', expression: '0 9 * * *' // Every day at 9 AM }, execute: async (job) => { // Generate report const report = await generateSalesReport(); // ⚠️ Pre-defined jobs have NO conversational context // Get user by ID from metadata const user = await User.get(job.metadata.userId); await user.send([{ type: 'text', text: report }]); } }); ``` **No Conversational Context:** LuaJob executes outside of user conversations. You MUST use `User.get(userId)` with a userId from metadata. The job does NOT have `jobInstance.user()` - that's only for dynamic jobs. Pre-defined jobs that are part of your agent configuration. Use with LuaAgent. ## When to Use For scheduled tasks defined at agent setup * Daily reports * Weekly summaries * Cleanup tasks * Monitoring jobs **User access:** `User.get(userId)` from metadata For tasks created on-demand from tools * User reminders * Follow-ups * One-time notifications * Context-specific tasks **User access:** `jobInstance.user()` (automatic!) **Key Difference:** Pre-defined LuaJob has NO user context. Use `User.get(userId)` with ID from metadata. Dynamic jobs (Jobs API) automatically have user context via `jobInstance.user()`. ## Comparison: LuaJob vs Jobs API Understanding user access in different job types: | Feature | LuaJob (Pre-defined) | Jobs API (Dynamic) | | -------------------- | ----------------------- | -------------------- | | **When Defined** | At agent setup | Runtime, from tools | | **User Context** | ❌ None | ✅ Automatic | | **Get User** | `User.get(userId)` | `jobInstance.user()` | | **userId Required?** | ✅ Yes (from metadata) | ❌ No (automatic) | | **Use Case** | Regular scheduled tasks | User-triggered tasks | **Example - Pre-defined LuaJob:** ```typescript theme={null} const job = new LuaJob({ metadata: { userId: 'user_abc123' }, // ← Store userId execute: async (job) => { const user = await User.get(job.metadata.userId); // ← Required } }); ``` **Example - Dynamic Job (Jobs API):** ```typescript theme={null} await Jobs.create({ execute: async (jobInstance) => { const user = await jobInstance.user(); // ← Automatic! } }); ``` ## Constructor ### new LuaJob(config) Creates a new pre-defined job. Job configuration object ## Configuration Parameters ### Required Fields Unique job name **Format**: lowercase, hyphens, underscores **Examples**: `'daily-report'`, `'weekly-cleanup'` When and how often the job runs Function that runs when the job triggers **Signature:** `(job: JobInstance) => Promise` ### Optional Fields Job description for documentation Static metadata available to execute function Maximum execution time in seconds **Default:** 300 (5 minutes) **Supported range:** 1–600 seconds. Values outside this range are rejected. Retry configuration ```typescript theme={null} retry: { maxAttempts: number; backoffSeconds?: number; } ``` Whether job is active **Default:** true ## Schedule Types ### Interval (Fixed intervals) ```typescript theme={null} schedule: { type: 'interval', seconds: number } ``` **Examples:** ```typescript theme={null} // Every 5 minutes schedule: { type: 'interval', seconds: 300 } // Every hour schedule: { type: 'interval', seconds: 3600 } // Every day (86400 seconds) schedule: { type: 'interval', seconds: 86400 } ``` ### Cron (Cron patterns) ```typescript theme={null} schedule: { type: 'cron', expression: string // Standard cron expression timezone?: string // Optional timezone (e.g., 'America/New_York') } ``` **Examples:** ```typescript theme={null} // Every day at 9 AM schedule: { type: 'cron', expression: '0 9 * * *' } // Every Monday at 8 AM EST schedule: { type: 'cron', expression: '0 8 * * 1', timezone: 'America/New_York' } // Every hour on the hour schedule: { type: 'cron', expression: '0 * * * *' } // First day of every month at midnight schedule: { type: 'cron', expression: '0 0 1 * *' } ``` ## Complete Examples ### Daily Sales Report ```typescript theme={null} import { LuaJob, Products, User } from 'lua-cli'; const dailySalesReport = new LuaJob({ name: 'daily-sales-report', description: 'Daily sales summary sent every morning', metadata: { userId: 'user_abc123', // Store userId to send report to reportTime: '09:00', timezone: 'America/New_York' }, schedule: { type: 'cron', expression: '0 9 * * *', // Every day at 9 AM timezone: 'America/New_York' }, execute: async (jobInstance) => { // Fetch sales data const products = await Products.get(1, 100); const totalValue = products.reduce((sum, p) => sum + (p.price || 0), 0); const topProducts = products.sort((a, b) => b.price - a.price).slice(0, 5); // Build report const report = `📊 Daily Sales Report\n\n` + `Date: ${new Date().toLocaleDateString()}\n` + `Total Products: ${products.length}\n` + `Catalog Value: $${totalValue.toFixed(2)}\n\n` + `Top 5 Products:\n` + topProducts.map(p => `- ${p.name}: $${p.price}`).join('\n'); // ⚠️ Pre-defined jobs have NO user context // Get user by ID from metadata const user = await User.get(jobInstance.metadata.userId); await user.send([{ type: 'text', text: report }]); return { success: true, productCount: products.length, totalValue, timestamp: new Date().toISOString() }; } }); export default dailySalesReport; ``` ### Weekly Cleanup Job ```typescript theme={null} import { LuaJob, Data, User } from 'lua-cli'; const weeklyCleanup = new LuaJob({ name: 'weekly-cleanup', description: 'Clean up old data every Sunday', metadata: { adminUserId: 'user_admin123' // Store admin userId to notify }, schedule: { type: 'cron', expression: '0 0 * * 0' // Every Sunday at midnight }, execute: async (jobInstance) => { // Delete old entries (older than 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const oldEntries = await Data.get('temp-data', 1000); let deletedCount = 0; for (const entry of oldEntries) { const createdAt = new Date(entry.createdAt); if (createdAt < thirtyDaysAgo) { await Data.delete('temp-data', entry.id); deletedCount++; } } // Send summary to admin // ⚠️ Pre-defined jobs have NO user context // Get user by ID from metadata const user = await User.get(jobInstance.metadata.adminUserId); await user.send([{ type: 'text', text: `🧹 Weekly cleanup complete. Deleted ${deletedCount} old entries.` }]); return { success: true, deletedCount, timestamp: new Date().toISOString() }; } }); export default weeklyCleanup; ``` ### Hourly Monitoring ```typescript theme={null} import { LuaJob, env, User } from 'lua-cli'; const systemMonitor = new LuaJob({ name: 'system-monitor', description: 'Monitor system health every hour', metadata: { alertThreshold: 90, checkInterval: 'hourly', adminUserId: 'user_admin123' // Store admin userId for alerts }, schedule: { type: 'interval', seconds: 3600 // Every hour }, execute: async (jobInstance) => { // Check external API health try { const response = await fetch(`${env('API_URL')}/health`); const health = await response.json(); // Alert if threshold exceeded if (health.cpuUsage > jobInstance.metadata.alertThreshold) { // ⚠️ Pre-defined jobs have NO user context // Get user by ID from metadata const user = await User.get(jobInstance.metadata.adminUserId); await user.send([{ type: 'text', text: `⚠️ Alert: CPU usage is ${health.cpuUsage}%` }]); } return { success: true, cpuUsage: health.cpuUsage, memoryUsage: health.memoryUsage, timestamp: new Date().toISOString() }; } catch (error) { // Alert on failure const user = await User.get(jobInstance.metadata.adminUserId); await user.send([{ type: 'text', text: `🚨 System monitor failed: ${error.message}` }]); return { success: false, error: error.message, timestamp: new Date().toISOString() }; } }, retry: { maxAttempts: 3, backoffSeconds: 60 } }); export default systemMonitor; ``` ### Reminders with Metadata ```typescript theme={null} import { LuaJob, Data, User } from 'lua-cli'; const userReminders = new LuaJob({ name: 'check-reminders', description: 'Check and send due reminders every 5 minutes', schedule: { type: 'interval', seconds: 300 // Every 5 minutes }, execute: async (jobInstance) => { // Get reminders due now const now = new Date(); const reminders = await Data.get('reminders', 100); let sentCount = 0; for (const reminder of reminders) { const dueTime = new Date(reminder.data.dueAt); if (dueTime <= now && !reminder.data.sent) { // ⚠️ Pre-defined jobs have NO user context // Each reminder must store the userId to send to const user = await User.get(reminder.data.userId); await user.send([{ type: 'text', text: `⏰ Reminder: ${reminder.data.message}` }]); // Mark as sent await Data.update('reminders', reminder.id, { ...reminder.data, sent: true, sentAt: now.toISOString() }); sentCount++; } } return { success: true, checked: reminders.length, sent: sentCount, timestamp: now.toISOString() }; } }); export default userReminders; ``` ## Using with LuaAgent Jobs are added to your agent configuration: ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import dailySalesReport from './jobs/daily-sales-report'; import weeklyCleanup from './jobs/weekly-cleanup'; import systemMonitor from './jobs/system-monitor'; export const agent = new LuaAgent({ name: 'my-agent', persona: '...', skills: [...], jobs: [ dailySalesReport, weeklyCleanup, systemMonitor ] }); ``` ## JobInstance API The execute function receives a `JobInstance` with these properties and methods: ### Properties | Property | Type | Description | | --------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `id` | `string` | Unique job identifier | | `name` | `string` | Job name | | `activeVersion` | `JobVersion` | The active version with schedule, timeout, etc. | | `metadata` | `object` | Job metadata | | `data` | `Job` | Full job data including all versions | | `execution` | `{ executionId, attempt, occurrenceId, scheduledTime? }` | Runtime execution metadata for the current run (see [Delivery Semantics](#delivery-semantics)). Undefined in local runs. | ### jobInstance.user() Get the user associated with the job. **Returns:** `Promise` ```typescript theme={null} const user = await jobInstance.user(); await user.send([{ type: 'text', text: 'Message' }]); ``` ### jobInstance.metadata Access job metadata. ```typescript theme={null} const threshold = jobInstance.metadata.alertThreshold; ``` ### jobInstance.updateMetadata(data) Update job metadata dynamically. ```typescript theme={null} await jobInstance.updateMetadata({ lastRun: new Date().toISOString(), runCount: (jobInstance.metadata.runCount || 0) + 1 }); ``` ### jobInstance.trigger(versionId?) Manually triggers the job execution (ignores schedule). Uses the active version by default. **Parameters:** * `versionId` (optional): Specific version to execute. Defaults to `activeVersion`. **Returns:** `Promise` ```typescript theme={null} // Trigger with default active version const execution = await jobInstance.trigger(); console.log('Execution ID:', execution.id); // Trigger with specific version const execution = await jobInstance.trigger('version_abc123'); ``` ### jobInstance.delete() Delete the job (or deactivates if it has versions). ```typescript theme={null} // Self-terminate after condition met if (conditionMet) { await jobInstance.delete(); } ``` ## Delivery Semantics Jobs run **at least once**. Each scheduled occurrence is durably queued and executed on isolated infrastructure, and a failed attempt is retried according to the job's `retry` configuration (`maxAttempts`, `backoffSeconds`). A job may therefore run **more than once** for a single scheduled occurrence, so keep side effects idempotent — guard them with your own state (for example, a flag in `metadata` or a record in [Data](/api/data)). * Retries happen **only when an attempt fails**, using your `maxAttempts` and `backoffSeconds`. * The platform caps attempts at **10** regardless of config — a higher `maxAttempts` is clamped to 10. Each execution receives metadata on `job.execution`: | Field | Description | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `occurrenceId` | Stable identifier for the logical occurrence — **the same value across every retry attempt**. Use it as your idempotency key so side effects run once per occurrence, no matter how many times it retries. | | `executionId` | Identifier for this individual attempt. **Changes on every retry** — do not use it for idempotency. | | `attempt` | 1-based attempt counter (`1` on the first run, `2` on the first retry, and so on). | | `scheduledTime` | For scheduled runs, the ISO-8601 fire time of the occurrence (a schedule-slot key). Absent for manually triggered runs. | ```typescript theme={null} execute: async (job) => { const { occurrenceId, executionId, attempt } = job.execution ?? {}; // Re-check your own state before repeating side effects if (attempt > 1) { console.log(`Retry ${attempt} of occurrence ${occurrenceId} (attempt id ${executionId})`); } // ... } ``` `job.execution` is only present when a job runs on Lua's infrastructure. It is `undefined` during local `lua test` / `lua dev` runs, which execute in-process — so always read it optionally (`job.execution?.occurrenceId`). ### Idempotency Because a job runs **at least once**, design your side effects to be safe to repeat. The `occurrenceId` is stable across all retries of one occurrence, so it makes a natural idempotency key: record it after the side effect succeeds, and skip the work if you've already processed it. Attempts of one occurrence never run concurrently, so a simple check-then-write guard like the one below is safe. ```typescript theme={null} execute: async (job) => { const occurrenceId = job.execution?.occurrenceId; // Skip if this occurrence's side effect already ran on an earlier attempt if (occurrenceId) { const done = await Data.get('processed_occurrences', { occurrenceId }); if (done.data.length > 0) return; } await chargeCustomer(); // the side effect that must happen exactly once // Mark it processed so a later retry is a no-op if (occurrenceId) { await Data.create('processed_occurrences', { occurrenceId }); } } ``` Prefer `occurrenceId` over `executionId` for deduplication: `executionId` is re-minted on every retry, so keying on it would let each attempt's side effect through. For scheduled jobs you can instead key on `scheduledTime` when you want one run per schedule slot. ## Best Practices Use cron for specific times, interval for regular checks ```typescript theme={null} // ✅ Cron for specific times schedule: { type: 'cron', expression: '0 9 * * *' } // 9 AM daily // ✅ Interval for regular polling schedule: { type: 'interval', seconds: 300 } // Every 5 min ``` Jobs should not throw - return error status instead ```typescript theme={null} execute: async (job) => { try { await doWork(); return { success: true }; } catch (error) { console.error('Job failed:', error); return { success: false, error: error.message }; } } ``` Configure retries for important operations ```typescript theme={null} retry: { maxAttempts: 3, backoffSeconds: 60 // Retry after 1 minute } ``` Set appropriate timeouts and avoid long-running operations ```typescript theme={null} timeout: 120, // 2 minutes max (in seconds) ``` ## Cron Pattern Reference ``` ┌───────────── minute (0 - 59) │ ┌───────────── hour (0 - 23) │ │ ┌───────────── day of month (1 - 31) │ │ │ ┌───────────── month (1 - 12) │ │ │ │ ┌───────────── day of week (0 - 6) (0 = Sunday) │ │ │ │ │ * * * * * ``` **Common Patterns:** * `0 * * * *` - Every hour on the hour * `0 9 * * *` - Every day at 9 AM * `0 9 * * 1` - Every Monday at 9 AM * `0 0 1 * *` - First day of every month at midnight * `*/15 * * * *` - Every 15 minutes * `0 9-17 * * 1-5` - 9 AM to 5 PM, Monday to Friday ## Invoking an Agent from a Pre-defined Job Use `Agents.invoke` to delegate work to a conversational agent from inside a scheduled job. Pre-defined jobs have no ambient user, so pass a `userId` when you have one (e.g. from job metadata). When no `userId` is available, omit it and the invocation runs without user identity — no conversation history is stored. Use `Agents.invoke` here instead of calling `/chat/generate` or `/chat/stream` with `fetch`. The runtime handles agent invocation authentication, so a job does not need an API base URL or bearer token. To invoke the same agent that owns the job, pass that agent's ID as the first argument. Self-invocation is supported, but the runtime does not currently provide a `self` sentinel or ambient current-agent ID. Reusable templates must therefore still configure the deployed agent ID. If the invoked turn uses `User.get()`, `User.send()`, or another user-scoped tool, also pass a real `userId`; the job cannot infer the installer or recipient. ```typescript theme={null} import { LuaJob, Agents, User } from 'lua-cli'; // With a known user — invocation runs in their context const userReminderJob = new LuaJob({ name: 'weekly-summary', schedule: { type: 'cron', expression: '0 9 * * 1' }, metadata: { userId: 'user_abc123' }, execute: async (job) => { const result = await Agents.invoke('summary-agent', { prompt: 'Generate the weekly activity summary for this user.', userId: job.metadata.userId, // conversation history stored for this user }); const user = await User.get(job.metadata.userId); await user.send([{ type: 'text', text: result.text }]); return { sent: true }; }, }); // Without a user — no conversation history stored const dailyDigestJob = new LuaJob({ name: 'daily-digest-generator', schedule: { type: 'cron', expression: '0 6 * * *' }, execute: async (job) => { const result = await Agents.invoke('digest-agent', { prompt: 'Generate the daily digest.', // userId omitted — invocation runs without user identity }); return { digest: result.text }; }, }); ``` Full documentation for Agents.invoke — options, output shape, error handling, and more examples ## Comparison: LuaJob vs Jobs API | Feature | LuaJob (Pre-defined) | Jobs API (Dynamic) | | ----------------- | ----------------------- | ------------------------- | | **When Defined** | At agent setup | Runtime, from tools | | **Use Case** | Regular scheduled tasks | On-demand, user-triggered | | **Schedule** | Interval or Cron | Once, Interval, or Cron | | **Examples** | Daily reports, cleanup | Reminders, follow-ups | | **Configuration** | Static in code | Dynamic with tool input | ## Related APIs Dynamic job creation Invoke another agent from a job Agent configuration Send messages Store and retrieve data ## See Also * [Jobs API](/api/jobs) - Dynamic job creation * [Agents API](/api/agents) - Invoking agents from scheduled jobs * [Workflows Concept](/concepts/workflows) * [Tool Examples](/examples/overview) # LuaMCPServer Source: https://docs.heylua.ai/api/luamcpserver Connect external tools via Model Context Protocol (MCP) servers ## Overview `LuaMCPServer` allows you to connect external MCP (Model Context Protocol) servers to your agent. MCP servers provide additional tools that your agent can use at runtime, enabling integration with APIs, databases, documentation services, and more. ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const docsServer = new LuaMCPServer({ name: 'docs', transport: 'streamable-http', url: 'https://mcp.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("MCP_API_KEY")}` }) }); export default docsServer; ``` **What is MCP?** The Model Context Protocol (MCP) is an open standard for connecting AI models to external tools and data sources. Learn more at [modelcontextprotocol.io](https://modelcontextprotocol.io). ## Why MCP Servers? Add tools without writing code - use existing MCP servers Compatible with any MCP-compliant server Connect to hosted MCP services via HTTP Access growing library of hosted MCP servers ## Transport Types MCP servers support two HTTP-based transport methods: **Best for:** Modern MCP servers, most use cases The modern MCP standard (spec 2025-03-26) with bidirectional communication. ```typescript theme={null} const server = new LuaMCPServer({ name: 'my-server', transport: 'streamable-http', url: 'https://mcp.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("API_KEY")}` }) }); ``` **Best for:** Older MCP servers that don't support Streamable HTTP Uses Server-Sent Events for server-to-client streaming. ```typescript theme={null} const server = new LuaMCPServer({ name: 'legacy-server', transport: 'sse', url: 'https://old-mcp.example.com/sse', headers: () => ({ 'Authorization': `Bearer ${env("API_KEY")}` }) }); ``` **stdio transport not supported yet**: Local MCP servers (using `npx`, `node`, etc.) are not supported yet. Please use remote MCP servers with `streamable-http` or `sse` transport instead. ## Constructor ### new LuaMCPServer(config) Creates a new MCP server configuration. MCP server configuration object ## Configuration Parameters ### Required Fields Unique identifier for the MCP server **Format**: lowercase, hyphens allowed **Examples**: `'docs-server'`, `'api-gateway'`, `'database'` Transport protocol for communication * `'streamable-http'` - Modern MCP standard (recommended) * `'sse'` - Legacy Server-Sent Events transport URL of the remote MCP server Can be a static string or a function that returns the URL at runtime using `env()`. **Examples**: * `'https://mcp.example.com/mcp'` * `() => env("MCP_SERVER_URL")` ### Optional Fields HTTP headers to send with requests Can be a static object or a function that returns headers at runtime using `env()`. **Example (static)**: `{ 'X-Custom-Header': 'value' }` **Example (dynamic)**: `() => ({ 'Authorization': \`Bearer \$\` })\` Timeout in milliseconds for server operations **Default**: `60000` (60 seconds) ## Examples ### Documentation Server ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const docsServer = new LuaMCPServer({ name: 'docs', transport: 'streamable-http', url: 'https://docs.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("DOCS_API_KEY")}` }), timeout: 30000 }); export default docsServer; ``` ### API Gateway ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const apiServer = new LuaMCPServer({ name: 'api-gateway', transport: 'streamable-http', url: 'https://api.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("API_TOKEN")}`, 'X-Api-Version': '2024-01' }), timeout: 45000 }); export default apiServer; ``` ### Database Service ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const dbServer = new LuaMCPServer({ name: 'database', transport: 'streamable-http', url: 'https://db-mcp.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("DB_API_KEY")}`, 'X-Database': 'production' }), timeout: 60000 }); export default dbServer; ``` ### Dynamic URL with env() ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const server = new LuaMCPServer({ name: 'dynamic-server', transport: 'streamable-http', url: () => env("MCP_SERVER_URL"), headers: () => ({ 'Authorization': `Bearer ${env("API_TOKEN")}` }) }); export default server; ``` ### Legacy SSE Server ```typescript theme={null} import { LuaMCPServer, env } from 'lua-cli'; const legacyServer = new LuaMCPServer({ name: 'legacy-api', transport: 'sse', url: 'https://old-mcp.example.com/sse', headers: () => ({ 'Authorization': `Bearer ${env("API_KEY")}` }) }); export default legacyServer; ``` ## Using with LuaAgent MCP servers are added to your agent configuration: ```typescript theme={null} import { LuaAgent, LuaSkill, env } from 'lua-cli'; const docsServer = new LuaMCPServer({ name: 'docs', transport: 'streamable-http', url: 'https://docs.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("DOCS_API_KEY")}` }) }); const coreSkill = new LuaSkill({ name: 'core-skill', description: 'Core functionality', tools: [...] }); export const agent = new LuaAgent({ name: 'docs-assistant', persona: 'You are an assistant with access to documentation.', skills: [coreSkill], // Add MCP servers mcpServers: [docsServer] }); ``` ## Lifecycle Management ### Compile During `lua compile`, MCP servers are: 1. Detected from your source code 2. Registered with the server (if new) 3. Assigned an ID stored in `lua.skill.yaml` 4. Configuration written to `dist/mcp-servers.json` ### Push Push individual MCP server: ```bash theme={null} lua push mcp # Select server from list ``` Or push all components: ```bash theme={null} lua push all --force ``` ### Activate / Deactivate MCP servers start inactive. Activate to make tools available: ```bash theme={null} lua mcp activate # Select server to activate lua mcp deactivate # Select server to deactivate ``` ### List / Delete ```bash theme={null} lua mcp list # Show all MCP servers lua mcp delete # Remove an MCP server ``` ## YAML Configuration After compilation, `lua.skill.yaml` tracks MCP servers: ```yaml theme={null} agent: agentId: your-agent-id persona: ... mcpServers: - name: docs mcpServerId: mcp_abc123 - name: api-gateway mcpServerId: mcp_def456 ``` The YAML only stores `name` and `mcpServerId`. The full configuration (url, headers, etc.) lives in your source code. ## Best Practices Always use `env()` for API keys and tokens ```typescript theme={null} // ✅ Good - resolved at runtime headers: () => ({ 'Authorization': `Bearer ${env("API_KEY")}` }) // ❌ Bad - hardcoded secret headers: { 'Authorization': 'Bearer sk_live_xxxx' } ``` Adjust timeout based on expected operation duration ```typescript theme={null} // Quick operations timeout: 10000 // 10 seconds // Database queries timeout: 30000 // 30 seconds // Heavy processing timeout: 120000 // 2 minutes ``` Prefer `streamable-http` transport for new MCP server integrations ```typescript theme={null} // ✅ Recommended - modern standard transport: 'streamable-http' // Use only for legacy servers transport: 'sse' ``` Keep unused servers deactivated to reduce overhead ```bash theme={null} # Activate only when needed lua mcp activate docs-server # Deactivate when done lua mcp deactivate docs-server ``` ## Troubleshooting Make sure the server is activated: ```bash theme={null} lua mcp list # Check status lua mcp activate # Activate if needed ``` Check the URL is accessible and increase timeout: ```typescript theme={null} const server = new LuaMCPServer({ transport: 'streamable-http', url: 'https://...', timeout: 120000 // Increase timeout }); ``` Verify your API key is correct and set in environment: ```bash theme={null} # Check environment variable is set lua env production # Set the variable lua env production -k API_KEY -v "your-api-key" ``` Local MCP servers (stdio) are no longer supported. Migrate to remote servers: ```typescript theme={null} // ❌ No longer supported transport: 'stdio', command: 'npx', args: [...] // ✅ Use remote transport instead transport: 'streamable-http', url: 'https://mcp.example.com/mcp', headers: () => ({ ... }) ``` ## Related APIs Agent configuration with MCP servers Custom tool collections Managing environment variables CLI management commands ## See Also * [MCP Servers Overview](/overview/mcp-servers) - Conceptual introduction * [MCP Command](/cli/mcp-command) - CLI management * [LuaAgent](/api/luaagent) - Adding MCP servers to your agent * [Use Lua with Claude & Cursor](/mcp-for-builders) - Connect Lua's docs to your AI coding assistant # LuaSkill Source: https://docs.heylua.ai/api/luaskill Collection of related tools that give your AI specific capabilities ## Overview `LuaSkill` is the main class for defining a skill - a collection of related tools that your AI agent can use. ```typescript theme={null} import { LuaSkill } from 'lua-cli'; const skill = new LuaSkill({ name: "my-skill", description: "Brief description of the skill", context: "Detailed instructions for the AI", tools: [new MyTool1(), new MyTool2()] }); ``` ## Constructor ### new LuaSkill(config) Creates a new skill instance. Skill configuration object ### Configuration Parameters Unique identifier for the skill **Format**: lowercase, alphanumeric, hyphens only **Examples**: `"weather-skill"`, `"product-catalog-skill"` Brief description (1-2 sentences) of what the skill does This appears in skill listings and helps users understand the skill's purpose. Detailed instructions for the AI on when and how to use the tools. **String form (recommended):** * Critical for proper tool selection * Write it like instructions to a smart assistant **Object form (channel-aware):** Only use when you need different instructions per channel. * `base` — Always rendered, on every channel * `voice` — Appended to `base` on voice channels, ignored on text * `text` — Appended to `base` on text channels (web, WhatsApp, SMS), ignored on voice See [Channel-aware Prompts](/concepts/channel-aware-prompts) for details. Array of tool instances to include in the skill Can be empty initially and tools added later with `addTool()` or `addTools()`. ```typescript theme={null} async condition(): Promise ``` Optional gate for the whole skill. Return `true` to expose it, `false` to hide it. When it returns `false` the skill disappears completely: its tools can't be called **and** its name, its context, and its tool names are left out of the agent's prompt. The agent doesn't know the capability exists. See [Conditional Skills](#conditional-skills). ## Conditional Skills Use `condition` when a whole capability should be invisible to some users — tiering, entitlements, per-customer features. ```typescript theme={null} import { LuaSkill, User } from 'lua-cli'; import CheckPointsTool from './tools/CheckPointsTool'; import RedeemPointsTool from './tools/RedeemPointsTool'; const loyaltySkill = new LuaSkill({ name: 'loyalty-rewards', description: 'Loyalty points balance and reward redemption', context: ` This skill manages the loyalty programme. Tool Usage: - check_points: Use when the customer asks about their balance. - redeem_points: Use when the customer wants to spend points. Confirm the reward first. `, condition: async () => { const user = await User.get(); return user.data?.loyaltyEnrolled === true; }, tools: [new CheckPointsTool(), new RedeemPointsTool()], }); ``` A customer who isn't enrolled talks to an agent that has never heard of a loyalty programme. There is no `check_points` tool to refuse, and no loyalty context to leak. How it behaves: * **Evaluated on every message, for the current user.** Results are not cached between turns, so the gate always reflects current state. * **Runs like your tool code.** Has access to all Platform APIs (`User`, `Data`, `Lua`, `Products`, etc.). * **Absent condition means always on.** Existing skills are unaffected. * **Skill-level short-circuits tool-level.** If the skill is hidden, its tools' own `condition` functions never run. If you author a skill as a `LuaSkill` subclass rather than a config object, declare `condition` as a class field or method — the semantics are identical. **Fail-closed behavior:** If your condition function throws an error or times out (30s), the skill is hidden. Design for that — a flaky third-party call inside a condition will hide the capability from users who should have it. Prefer a flag you already store on the user record over a live external lookup on every message. ### Skill condition vs tool condition Both gates take the same shape. They differ in what the agent still knows about. | | Tool `condition` | Skill `condition` | | ------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------- | | Scope | One tool | Every tool in the skill | | Tool callable when `false` | No | No | | Tool name in the prompt | Removed | Removed | | Skill name and context in the prompt | Still rendered | Removed | | Agent can mention the capability | Yes — the skill's context still describes it | No — it doesn't know it exists | | Reach for it when | The capability is known to the user but currently unavailable | The *existence* of the capability is itself sensitive | Gate individual tools with [`LuaTool.condition`](/api/luatool#condition) when the rest of the skill still applies — for example, hiding `cancel_order` while `track_order` stays available. ## Methods ### addTool() Adds a single tool to the skill. ```typescript theme={null} skill.addTool(tool: LuaTool): void ``` Tool instance to add **Example:** ```typescript theme={null} const skill = new LuaSkill({ name: "my-skill", description: "My skill", context: "..." }); skill.addTool(new GetWeatherTool()); skill.addTool(new CreateOrderTool()); ``` **Validation:** * Tool name must be unique within the skill * Tool name must only contain: `a-z`, `A-Z`, `0-9`, `-`, `_` * Throws error if validation fails ### addTools() Adds multiple tools to the skill at once. ```typescript theme={null} skill.addTools(tools: LuaTool[]): void ``` Array of tool instances to add **Example:** ```typescript theme={null} skill.addTools([ new GetWeatherTool(), new CreateOrderTool(), new SearchProductsTool() ]); ``` **Validation:** * All tools validated before adding * Atomic operation (all or nothing) * Throws error if any validation fails ## Examples ### Basic Skill ```typescript theme={null} import { LuaSkill, LuaTool } from 'lua-cli'; import { z } from 'zod'; class HelloTool implements LuaTool { name = "say_hello"; description = "Say hello to a user"; inputSchema = z.object({ name: z.string() }); async execute(input: any) { return { message: `Hello, ${input.name}!` }; } } const helloSkill = new LuaSkill({ name: "hello-skill", description: "A simple greeting skill", context: "Use say_hello when users want to be greeted", tools: [new HelloTool()] }); ``` ### Weather Skill ```typescript theme={null} import { LuaSkill } from 'lua-cli'; import GetWeatherTool from './tools/GetWeatherTool'; import GetForecastTool from './tools/GetForecastTool'; const weatherSkill = new LuaSkill({ name: "weather-skill", description: "Provides weather information for any city worldwide", context: ` This skill provides weather information. - Use get_weather for current conditions - Use get_forecast for 7-day predictions Always include the city name in responses. Mention temperature in user's preferred units. `, tools: [ new GetWeatherTool(), new GetForecastTool() ] }); ``` ### E-commerce Skill ```typescript theme={null} import { LuaSkill } from 'lua-cli'; import { SearchProductsTool, CreateProductTool, UpdateProductTool } from './tools/ProductTools'; const ecommerceSkill = new LuaSkill({ name: "ecommerce-skill", description: "Complete e-commerce product management", context: ` This skill manages an e-commerce product catalog. Tool Usage: - search_products: When users describe what they're looking for - create_product: When adding new items (admin only) - update_product: When modifying prices, stock, or details Guidelines: - Always show prices with currency - Mention stock availability - Suggest related products when relevant - Confirm changes before updating `, tools: [ new SearchProductsTool(), new CreateProductTool(), new UpdateProductTool() ] }); ``` ### Adding Tools Dynamically ```typescript theme={null} const skill = new LuaSkill({ name: "dynamic-skill", description: "Skill with dynamically added tools", context: "Tools will be added based on configuration" }); // Add tools conditionally if (config.enableWeather) { skill.addTool(new GetWeatherTool()); } if (config.enableOrders) { skill.addTools([ new CreateOrderTool(), new TrackOrderTool() ]); } ``` ## Writing Good Context The `context` field is critical for AI tool selection. Follow these guidelines: ### Structure ```typescript theme={null} context: ` [What this skill does - 1 sentence] Tool Usage: - tool_1: [When to use] [What it returns] - tool_2: [When to use] [What it returns] Guidelines: - [Important rules] - [Edge cases] - [User experience tips] ` ``` ### Good Context Example ```typescript theme={null} context: ` This skill manages customer orders for a coffee shop. Tool Usage: - show_menu: Use when customers ask what's available. Returns drinks and food. - create_order: Use when taking an order. Confirm items and sizes first. - modify_order: Use to add/remove items. Ask which item to modify. - finalize_order: Use when confirmed. Returns total and estimated time. Guidelines: - Always ask about drink sizes (small/medium/large) - Mention daily special when showing menu - Confirm total before finalizing order - Ask about dietary restrictions for food ` ``` ### Poor Context Example ```typescript theme={null} context: "A skill with tools for stuff" // ❌ Too vague ``` ## Best Practices ```typescript theme={null} // ✅ Good name: "weather-skill" name: "product-catalog-skill" name: "customer-support-skill" // ❌ Bad name: "skill1" name: "my_skill" name: "test" ``` ```typescript theme={null} // ✅ Good description: "Provides real-time weather information and 7-day forecasts for cities worldwide" // ❌ Bad description: "Weather stuff" ``` The context field should include: * Overview of skill purpose * When to use each tool * Important guidelines * Edge cases to handle Think of it as training documentation for the AI. Group tools that work together: ```typescript theme={null} // ✅ Good - Related tools together const orderSkill = new LuaSkill({ tools: [ new CreateOrderTool(), new UpdateOrderTool(), new CancelOrderTool(), new TrackOrderTool() ] }); // ❌ Bad - Unrelated tools mixed const messySkill = new LuaSkill({ tools: [ new GetWeatherTool(), new CreateOrderTool(), new SendEmailTool() ] }); ``` ## Multi-Skill Projects You can define multiple skills in one project: ```typescript theme={null} // Product browsing const catalogSkill = new LuaSkill({ name: "catalog-skill", description: "Product browsing and search", tools: [new SearchProductsTool(), new GetProductTool()] }); // Shopping cart const cartSkill = new LuaSkill({ name: "cart-skill", description: "Shopping cart management", tools: [new CreateBasketTool(), new AddItemTool()] }); // Order processing const orderSkill = new LuaSkill({ name: "order-skill", description: "Order creation and tracking", tools: [new CreateOrderTool(), new TrackOrderTool()] }); ``` Each skill gets its own `skillId` in `lua.skill.yaml` and can be deployed independently. ## Type Definitions ```typescript theme={null} interface LuaSkillConfig { name?: string; description: string; context: string; tools?: LuaTool[]; condition?: () => Promise; } class LuaSkill { constructor(config: LuaSkillConfig); addTool(tool: LuaTool): void; addTools(tools: LuaTool[]): void; } ``` ## Next Steps Learn how to implement tools Follow a complete tutorial Explore the example project Understand the architecture # LuaTool Source: https://docs.heylua.ai/api/luatool Interface for implementing individual AI tool functions ## Overview `LuaTool` is the interface that all tools must implement. A tool is a single function that the AI can call to accomplish a specific task. ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class MyTool implements LuaTool { name = "my_tool"; description = "What the tool does"; inputSchema = z.object({ param: z.string() }); async execute(input: any) { return { result: "success" }; } } ``` ## Interface Definition ```typescript theme={null} interface LuaTool { name: string; description: string; inputSchema: TInput; execute: (input: z.infer) => Promise; condition?: () => Promise; } ``` ## Required Properties ### name Unique identifier for the tool. Tool name using only: `a-z`, `A-Z`, `0-9`, `-`, `_` **Examples**: `"get_weather"`, `"create-order"`, `"sendEmail123"` **Invalid**: `"get weather"`, `"tool.name"`, `"send@email"` ```typescript theme={null} // ✅ Good name = "get_weather"; name = "create-product"; name = "search_items"; // ❌ Bad name = "get weather"; // No spaces name = "tool.name"; // No dots name = "send@email"; // No special chars ``` ### description Clear, concise description of what the tool does. One sentence describing the tool's purpose Helps the AI understand when to use this tool ```typescript theme={null} // ✅ Good description = "Get current weather conditions for any city worldwide"; description = "Create a new product in the catalog with price and details"; description = "Search for products by name, category, or description"; // ❌ Bad description = "Gets data"; // Too vague description = "Does weather stuff"; // Unclear ``` ### inputSchema Zod schema that validates and types the input. Zod schema defining valid inputs Provides runtime validation and TypeScript types ```typescript theme={null} import { z } from 'zod'; // Simple inputSchema = z.object({ city: z.string() }); // With validation inputSchema = z.object({ email: z.string().email(), age: z.number().min(0).max(120) }); // With descriptions inputSchema = z.object({ city: z.string().describe("City name (e.g., 'London', 'Tokyo')"), units: z.enum(['metric', 'imperial']).describe("Temperature units") }); // With optional and default values inputSchema = z.object({ query: z.string(), limit: z.number().default(10), offset: z.number().optional() }); ``` ### execute Async function that implements the tool's logic. ```typescript theme={null} async execute(input: z.infer): Promise ``` * Input is automatically validated * Must return a JSON-serializable value * Can throw errors for failures ## Optional Properties ### condition Async function that determines if the tool should be available to the AI. ```typescript theme={null} async condition(): Promise ``` * Runs **before** the tool is offered to the AI * Return `true` to enable the tool, `false` to hide it * If the function throws an error, the tool is disabled (fail-closed) * Has access to all Platform APIs (User, Data, Products, etc.) Use conditions to dynamically enable/disable tools based on: * User subscription status (premium features) * User verification or account status * Feature flags or A/B testing * Region-specific functionality * Time-based access ```typescript theme={null} import { LuaTool, User } from 'lua-cli'; import { z } from 'zod'; export default class PremiumSearchTool implements LuaTool { name = "premium_search"; description = "Advanced search with filters - premium users only"; inputSchema = z.object({ query: z.string(), filters: z.object({ minPrice: z.number().optional(), maxPrice: z.number().optional() }).optional() }); // Only show this tool to premium users condition = async () => { const user = await User.get(); return user.data?.isPremium === true; }; async execute(input: z.infer) { // This only runs if condition returned true return { results: [] }; } } ``` **Fail-closed behavior:** If your condition function throws an error or times out (30s), the tool is automatically disabled. This ensures tools aren't accidentally exposed when conditions can't be evaluated. A disabled tool is left out of the prompt's tool list, but the skill's name and context stay in the agent's prompt — the agent can still say "that's a premium feature, so I can't do that". To hide the capability entirely, put the same `condition` on the skill: see [Skill condition vs tool condition](/api/luaskill#skill-condition-vs-tool-condition). ## Implementation Examples ### Simple Tool ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class GreetTool implements LuaTool { name = "greet_user"; description = "Greet a user by name"; inputSchema = z.object({ name: z.string() }); async execute(input: z.infer) { return { message: `Hello, ${input.name}!`, timestamp: new Date().toISOString() }; } } ``` ### External API Tool ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class GetWeatherTool implements LuaTool { name = "get_weather"; description = "Get current weather for a city"; inputSchema = z.object({ city: z.string().describe("City name"), units: z.enum(['metric', 'imperial']).optional().default('metric') }); async execute(input: z.infer) { const { city, units } = input; // Call external API const response = await fetch( `https://api.weather.com/v1/weather?city=${city}&units=${units}` ); if (!response.ok) { throw new Error(`Weather API error: ${response.statusText}`); } const data = await response.json(); return { city: data.location, temperature: data.temp, condition: data.condition, humidity: data.humidity }; } } ``` ### Platform API Tool ```typescript theme={null} import { LuaTool, Products } from 'lua-cli'; import { z } from 'zod'; export default class SearchProductsTool implements LuaTool { name = "search_products"; description = "Search for products by name or description"; inputSchema = z.object({ query: z.string().describe("Search query"), limit: z.number().min(1).max(100).default(10) }); async execute(input: z.infer) { const results = await Products.search(input.query); // Take only requested number of results const products = results.products.slice(0, input.limit); return { products: products.map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, inStock: p.inStock })), total: results.length, showing: products.length }; } } ``` ### Environment Variables Tool ```typescript theme={null} import { LuaTool, env } from 'lua-cli'; import { z } from 'zod'; export default class SendEmailTool implements LuaTool { name = "send_email"; description = "Send an email via SendGrid"; inputSchema = z.object({ to: z.string().email(), subject: z.string(), body: z.string() }); async execute(input: z.infer) { // Get API key from environment const apiKey = env('SENDGRID_API_KEY'); if (!apiKey) { throw new Error('SENDGRID_API_KEY not configured'); } // Send email const response = await fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ personalizations: [{ to: [{ email: input.to }] }], from: { email: 'noreply@example.com' }, subject: input.subject, content: [{ type: 'text/plain', value: input.body }] }) }); if (!response.ok) { throw new Error(`Email failed: ${response.statusText}`); } return { success: true, message: `Email sent to ${input.to}` }; } } ``` ### Multi-Step Tool ```typescript theme={null} import { LuaTool, Products, Baskets } from 'lua-cli'; import { z } from 'zod'; export default class QuickCheckoutTool implements LuaTool { name = "quick_checkout"; description = "Search, add to cart, and checkout in one step"; inputSchema = z.object({ productName: z.string(), quantity: z.number().min(1).default(1), shippingAddress: z.object({ street: z.string(), city: z.string(), zip: z.string() }) }); async execute(input: z.infer) { // Step 1: Search for product const products = await Products.search(input.productName); if (products.length === 0) { throw new Error(`Product not found: ${input.productName}`); } const product = products.products[0]; // Step 2: Create basket const basket = await Baskets.create({ currency: 'USD' }); // Step 3: Add product await Baskets.addItem(basket.id, { id: product.id, price: product.price, quantity: input.quantity }); // Step 4: Checkout const order = await Baskets.placeOrder({ shippingAddress: input.shippingAddress, paymentMethod: 'stripe' }, basket.id); return { orderId: order.id, product: product.name, quantity: input.quantity, total: `$${(product.price * input.quantity).toFixed(2)}`, message: 'Order created successfully' }; } } ``` ### Conditional Tool (Premium Feature) ```typescript theme={null} import { LuaTool, User, Products } from 'lua-cli'; import { z } from 'zod'; export default class PremiumAdvancedSearchTool implements LuaTool { name = "premium_advanced_search"; description = "Advanced search with filters and sorting - premium users only"; inputSchema = z.object({ query: z.string().describe("Search query"), filters: z.object({ category: z.string().optional(), minPrice: z.number().optional(), maxPrice: z.number().optional() }).optional(), sortBy: z.enum(["relevance", "price_asc", "price_desc", "newest"]).optional() }); // Condition: Only show to premium users condition = async () => { const user = await User.get(); // Check subscription status const isPremium = user.data?.subscription === "premium" || user.data?.isPremium === true; return isPremium; }; async execute(input: z.infer) { const { query, filters, sortBy } = input; const searchResult = await Products.search({ query, limit: 20 }); let products = searchResult.products; // Apply price filters if (filters?.minPrice !== undefined || filters?.maxPrice !== undefined) { products = products.filter(p => { if (filters.minPrice && p.price < filters.minPrice) return false; if (filters.maxPrice && p.price > filters.maxPrice) return false; return true; }); } return { query, totalResults: products.length, results: products.slice(0, 10), sortedBy: sortBy || "relevance" }; } } ``` ## Reusing Tools Across Agents When multiple agents share the same tool logic — same API, same auth, same shape — but differ on a couple of config values, declare the shared parts on an abstract base and have each agent's tool extend it. The compiler walks the full `extends` chain, so a leaf class is detected as a tool whether it extends `LuaTool` directly or through any number of intermediate bases. ```typescript theme={null} // Shared base — published once, imported by every agent import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export abstract class SearchTool implements LuaTool { name = 'search'; description = 'Search the knowledge base.'; inputSchema = z.object({ query: z.string() }); // Subclasses override this. The default lets the type-check pass // for the abstract base; concrete subclasses must set a real value. searchPath: string = '/default/search'; async execute(input: { query: string }) { const r = await fetch(`https://api.example.com${this.searchPath}`, { method: 'POST', body: JSON.stringify({ q: input.query }), }); return r.json(); } } ``` ```typescript theme={null} // Per-agent leaf — only what differs import { SearchTool } from '@my-org/shared'; export class BlackshipSearchTool extends SearchTool { name = 'search_blackship'; searchPath = '/blk/search'; } ``` Register the leaf class on a skill — pass the class itself, not an instance: ```typescript theme={null} import { LuaSkill } from 'lua-cli'; import { BlackshipSearchTool } from './tools/BlackshipSearch'; export default new LuaSkill({ name: 'support', description: 'Customer support tools', context: 'Use search_blackship to look things up.', tools: [BlackshipSearchTool], }); ``` Field initializers on the leaf run during construction, so `this.searchPath` inside the parent's `execute` resolves to `'/blk/search'`. The leaf inherits `inputSchema`, `description`, and `execute` from the parent unless it overrides them. Don't pass constructor arguments at the reference site: ```typescript theme={null} // ❌ The string is silently dropped — one tool artifact is shared across // every reference and there's no carrier for per-reference args. tools: [new BlackshipSearchTool('/blk/search')] ``` The compiler reports `lua/constructor-args-dropped` if it sees this. Use a subclass with a field override instead. ## Input Schema Patterns ### Optional Fields ```typescript theme={null} inputSchema = z.object({ required: z.string(), optional: z.string().optional(), withDefault: z.string().default('default value') }); ``` ### Validation ```typescript theme={null} inputSchema = z.object({ email: z.string().email(), age: z.number().min(18).max(120), phone: z.string().regex(/^\+?[1-9]\d{1,14}$/), url: z.string().url(), password: z.string().min(8) }); ``` ### Nested Objects ```typescript theme={null} inputSchema = z.object({ user: z.object({ name: z.string(), email: z.string().email() }), preferences: z.object({ notifications: z.boolean(), language: z.string() }).optional() }); ``` ### Arrays ```typescript theme={null} inputSchema = z.object({ items: z.array(z.object({ id: z.string(), quantity: z.number() })), tags: z.array(z.string()).optional() }); ``` ### Enums ```typescript theme={null} inputSchema = z.object({ status: z.enum(['pending', 'active', 'completed']), priority: z.enum(['low', 'medium', 'high']).default('medium') }); ``` ## Error Handling ### Throwing Errors ```typescript theme={null} async execute(input: any) { // Validate business logic if (input.amount <= 0) { throw new Error("Amount must be positive"); } // API errors const response = await fetch(url); if (!response.ok) { throw new Error(`API error: ${response.statusText}`); } // Not found errors const item = await findItem(input.id); if (!item) { throw new Error(`Item not found: ${input.id}`); } return result; } ``` ### Try-Catch Pattern ```typescript theme={null} async execute(input: any) { try { const result = await externalService.call(input); if (!result.success) { throw new Error(result.error || 'Operation failed'); } return result.data; } catch (error) { // Add context to errors throw new Error(`Failed to process request: ${error.message}`); } } ``` ## Return Value Patterns ### Structured Data ```typescript theme={null} // ✅ Good - Structured return { success: true, data: { id, name, price }, metadata: { timestamp, version } }; // ❌ Bad - Unstructured string return "Product created with ID 123"; ``` ### Lists ```typescript theme={null} return { items: [...], total: 100, page: 1, hasMore: true }; ``` ### Status Updates ```typescript theme={null} return { status: 'completed', message: 'Order shipped successfully', trackingNumber: 'ABC123', estimatedDelivery: '2025-10-10' }; ``` ## Best Practices ```typescript theme={null} async execute(input: z.infer) { // input is fully typed! const { city, units } = input; } ``` ```typescript theme={null} inputSchema = z.object({ city: z.string().describe("City name (e.g., 'London', 'Tokyo')"), units: z.enum(['metric', 'imperial']).describe("Temperature units") }); ``` Always return objects, not strings: ```typescript theme={null} // ✅ Good return { temperature: 72, condition: "sunny" }; // ❌ Bad return "The temperature is 72 and sunny"; ``` Provide helpful error messages: ```typescript theme={null} if (!apiKey) { throw new Error('API_KEY environment variable is required. Set it in .env file.'); } ``` One tool = one responsibility: ```typescript theme={null} // ✅ Good - Single purpose class CreateProductTool { ... } class UpdateProductTool { ... } // ❌ Bad - Multiple purposes class ProductTool { // Does create, update, delete, search... } ``` ## Next Steps Learn about skills Use built-in APIs See working examples Complete tutorial # LuaTrigger Source: https://docs.heylua.ai/api/luatrigger Declarative triggers that wake your agent on external events — verify, filter, and transform, with no execute function ## Overview `LuaTrigger` (created with `defineTrigger`) wakes your agent when an external service posts to a trigger URL. Unlike a webhook, a trigger has **no `execute` function** — the agent turn itself does the work. Your only customisation surface is three optional, declarative slots that run server-side before the agent is invoked: 1. **`verify`** — authenticate the request (e.g. an HMAC signature check). Return `false` and the request is rejected with HTTP 401; the agent never runs. 2. **`filter`** — decide whether this event matters. Return `false` and the sender gets HTTP 200, but no agent invocation happens. 3. **`transform`** — shape what the agent receives. Return a message string (or a full invocation input) instead of the raw payload. ```typescript theme={null} import { defineTrigger } from 'lua-cli'; import { z } from 'zod'; export default defineTrigger({ name: 'order-created', description: 'Fires when the shop reports a new order', inputSchema: z.object({ type: z.string(), data: z.object({ orderId: z.string(), total: z.number() }), }), filter: (ctx) => ctx.body.type === 'order.created', transform: (ctx) => `New order ${ctx.body.data.orderId} for ${ctx.body.data.total}. Confirm it and notify the customer.`, }); ``` After `lua push`, every event that passes `verify` and `filter` starts an agent turn with the transformed message. There is no handler code to maintain — the agent's persona, skills, and tools take it from there. A trigger needs at least one of `verify`, `filter`, or `transform` — a trigger with none of the three is just a paste-anywhere URL trigger, which you can create without any code via [`lua triggers create`](/cli/triggers-command). ## Triggers vs Webhooks vs Jobs | | **LuaTrigger** | **LuaWebhook** | **LuaJob** | | --------------------- | --------------------------------------------- | -------------------------------------------- | ------------------------------------- | | **Purpose** | Wake the agent on an external event | Full request/response control | Scheduled or queued work | | **Handler code** | None — declarative slots only | Your `execute` function | Your `execute` function | | **Who does the work** | The agent (persona, skills, tools) | Your code | Your code | | **HTTP response** | Framework-owned (200/401/…) | Whatever `execute` returns | n/a | | **Best for** | "When X happens, have the agent deal with it" | Custom responses, side effects, syncing data | Cron schedules, background processing | Reach for `LuaWebhook` when the caller needs a specific response body or you want to run code without involving the agent. Reach for `LuaTrigger` when the right reaction to an event is *an agent turn* — triage this PR, answer this ticket, follow up on this order. See [LuaWebhook](/api/luawebhook) and [LuaJob](/api/luajob). ## Use Cases "A PR was assigned to me — triage it" with signature verification Wake the agent on a successful Stripe payment, ignore the rest Point a monitoring tool at the trigger URL; the agent investigates Any service that can POST JSON can start an agent turn ## Defining a Trigger Three authoring shapes are recognised — pick whichever fits your codebase: ```typescript defineTrigger (recommended) theme={null} import { defineTrigger } from 'lua-cli'; export default defineTrigger({ name: 'order-created', description: 'Fires when the shop reports a new order', filter: (ctx) => ctx.body.type === 'order.created', }); ``` ```typescript new LuaTrigger theme={null} import { LuaTrigger } from 'lua-cli'; export default new LuaTrigger({ name: 'order-created', description: 'Fires when the shop reports a new order', filter: (ctx) => ctx.body.type === 'order.created', }); ``` ```typescript class extends LuaTrigger theme={null} import { LuaTrigger, TriggerContext } from 'lua-cli'; export default class OrderCreatedTrigger extends LuaTrigger { name = 'order-created'; description = 'Fires when the shop reports a new order'; filter(ctx: TriggerContext) { return ctx.body.type === 'order.created'; } } ``` ## Configuration Parameters ### Required Fields Unique trigger name, used as the server-side identifier. **Format**: URL-safe — a lowercase letter followed by lowercase letters, digits, and hyphens (e.g. `'order-created'`, `'github-pr-assigned'`). Other names compile with a warning. Short description shown in trigger listings. It is a note for you — it is **not** sent to the agent. ### Slots (at least one required) Authentication gate. Return `false` (or a promise of `false`) to reject the request with **HTTP 401** and skip the agent entirely. Put HMAC signature checks here. **Signature:** `(ctx: TriggerContext) => boolean | Promise` Relevance gate, run after `verify`. Return `false` to acknowledge the event with **HTTP 200** but skip the agent — the way to intentionally ignore events you don't care about, without making the sender think delivery failed. **Signature:** `(ctx: TriggerContext) => boolean | Promise` Shapes the agent input, run after `filter`. Return either: * a **string** — becomes the agent's message for the turn, or * a **full invocation input object** (`{ messages | prompt, userId?, threadId?, systemPrompt?, ... }`) — you own the whole turn. Returning `null`/`undefined` is an error (the delivery fails with HTTP 500) — use `filter` to skip events, not `transform`. **Signature:** `(ctx: TriggerContext) => string | AgentInvocationInput | Promise` Omit `transform` to use the default payload-forwarding format described in [Default Agent Message](#default-agent-message). ### Optional Fields Event source. Only `'webhook'` (an HTTP POST to the trigger URL) is currently supported. Optional Zod schema describing the event body. To type `ctx.body` in your slots, also pass the payload type as the generic: `defineTrigger({ ... })`. ## TriggerContext Every slot receives the same context object: ```typescript theme={null} interface TriggerContext { body: T; // Parsed request body (type it via defineTrigger) rawBody?: string; // Exact unparsed request bytes (utf8) headers: Record; // Request headers — keys are lowercased query: Record; // Parsed query-string parameters triggerName: string; // This trigger's name source: string; // Event source ('webhook') } ``` **Compute HMAC signatures over `ctx.rawBody`, never over `JSON.stringify(ctx.body)`.** Providers like GitHub (`x-hub-signature-256: sha256=…`), Stripe, and Slack sign the exact bytes they send. Re-stringifying the parsed body does not reproduce those bytes (key order, whitespace, and unicode escapes all differ), so a signature computed from `ctx.body` will fail even for genuine requests. `rawBody` exists precisely for this. Header keys arrive **lowercased** — read `ctx.headers['x-hub-signature-256']`, not `ctx.headers['X-Hub-Signature-256']`. ## Slot Semantics Slots run server-side, in order, on every delivery. Their outcome decides the HTTP response the sender sees: | Outcome | HTTP response | Agent invoked? | Logged status | | -------------------------------------- | ------------- | ------------------------- | ------------------------ | | All slots pass | `200` | ✅ Yes (in the background) | `accepted` → `completed` | | `verify` returns `false` | `401` | ❌ No | `rejected_unverified` | | `filter` returns `false` | `200` | ❌ No | `skipped_filtered` | | Any slot throws | `500` | ❌ No | `failed` | | `transform` returns `null`/`undefined` | `500` | ❌ No | `failed` | | Trigger is deactivated | `200` | ❌ No | `skipped_inactive` | | Unknown URL or token | `404` | ❌ No | — | A few facts worth knowing: * **Slots run in a sandbox with a 15-second budget.** Keep them fast and computational — signature checks, field comparisons, string building. Don't make network calls from slots; if the agent needs external data, let the agent fetch it with its tools during the turn. * **`verify` failures return 401, not 500.** Webhook providers treat a 401 as a configuration error and won't retry-storm you the way they would on a 5xx. * **`filter` failures return 200.** The sender sees a successful delivery, so it won't retry an event you deliberately ignored. * **Node's built-in `crypto` module is available** in slots (for `createHmac`, `timingSafeEqual`), and [`env('KEY')`](/api/environment) reads your agent's environment variables — the sanctioned way to get secrets into a `verify` check. * **The agent turn is fire-and-forget.** Once the slots pass, the sender gets its 200 immediately; the agent runs in the background and the outcome lands in the execution log. ## Default Agent Message When you omit `transform`, the agent receives the event in a standard format. With an **instruction** set (via `lua triggers create --instruction "..."`): ``` [Trigger: ] Payload: ``` Without an instruction, the JSON payload directly follows the prefix — there is no `Payload:` label: ``` [Trigger: ] ``` * The `[Trigger: ]` prefix is always present, so the agent (and any follow-up turns) can tell what started the conversation. * The instruction is the place to tell the agent what to do with the event. * The payload is **capped at 50,000 characters**; anything longer is truncated. Providing a `transform` overrides all of this: * **Return a string** → the agent's message becomes `[Trigger: ] `. The trigger's instruction is *not* applied — your transform owns the message. This is the way to forward hand-picked fields from large payloads instead of hitting the 50k cap. * **Return an invocation input object** → you own the entire turn: message content, `userId` (to run as a specific user with conversation history), `threadId`, `systemPrompt`, and so on. The channel is always recorded as `trigger`. ```typescript theme={null} // Full-control transform: run the turn as a specific user transform: (ctx) => ({ prompt: `Order ${ctx.body.orderId} was refunded. Apologise and offer a discount code.`, userId: ctx.body.customerId, // conversation history is stored for this user }) ``` A transform-supplied `userId` takes precedence over the trigger's own bound user — see [Who the trigger turn runs as](#who-the-trigger-turn-runs-as). ## Who the trigger turn runs as Every trigger has a **bound user**, and the agent turn it fires runs as that person: * A trigger you create yourself — `lua push` of a `LuaTrigger`, or `lua triggers create` — binds **you**, the developer who created it. * A trigger that arrives with an installed [template](/marketplace/agent-templates) binds **the installer**. The binding is what makes user-scoped work inside a trigger-fired turn land on the right desk: an [`Inbox.push`](/api/inbox) from a tool running in the turn reaches the bound user, `User` reads and writes act on them, and the conversation lands in their history — instead of a context-less system run. **Identity precedence**, highest first: 1. **A transform-supplied `userId`.** A `transform` that returns a full invocation input with `userId` owns the turn's identity — the SDK author's explicit choice always wins. 2. **The trigger's bound user.** The default when the transform sets no `userId` (or there is no transform). 3. **System.** With no transform `userId` and no binding, the turn runs with a system identity and no ambient user. Three things the binding never does: * **It never comes from the request.** The bound user is set server-side when the trigger is created or installed — nothing in the webhook payload, headers, or URL can choose or change who the turn runs as. * **It doesn't survive duplication.** Duplicating an agent gives the copy's triggers fresh URLs and **no bound user** — a copy must never fire as the original's owner. Its turns run with system identity until the binding is re-established (re-create the trigger, or re-install the template on the copy). * **It doesn't outlive the user.** If the bound user's account is offboarded, the binding is cleared and the trigger falls back to system identity rather than acting for someone who left. ## Trigger URLs and Token Security Every trigger gets a URL of the form: ``` https://trigger.heylua.ai/trigger/{agentId}/{token} ``` * The **token is the secret** — anyone who has the full URL can fire the trigger (subject to your `verify` slot). Treat the URL like a credential. * Unknown agent, unknown token, or a mismatched pair all return the **same 404** — the URL shape leaks nothing about which part was wrong. * If a URL leaks, rotate it: `lua triggers rotate-token --trigger `. The old URL stops working immediately and the CLI prints the new one. A trigger **without** a `verify` slot accepts any request that has the URL. That is fine for low-stakes automation glue, but for anything that causes real side effects, add a `verify` slot with a proper signature check — don't rely on URL secrecy alone. ### Agents know their own URL When an agent is installed from a [template](/marketplace/agent-templates), the platform writes each webhook trigger's per-install URL into the agent's environment as `LUA_TRIGGER_URL__` — the trigger key upper-snaked, so `github-pr-assigned` becomes `LUA_TRIGGER_URL__GITHUB_PR_ASSIGNED`. The variable is readable with [`env()`](/api/environment) from tools **and** from trigger slots, is rewritten if the trigger's token is rotated, and is removed when the template is uninstalled. This is what makes **zero-input webhook setup** possible: instead of the installer copying a URL into a provider's settings screen, a setup tool in the template reads its own URL and registers it with the provider itself. ```typescript theme={null} import { LuaTool, env } from 'lua-cli'; import { z } from 'zod'; export class SetupWebhookTool implements LuaTool { name = 'setup_webhook'; description = 'Register this agent\'s trigger URL with the provider.'; inputSchema = z.object({}); async execute() { // Read at call time — the platform rewrites this on token rotation. const triggerUrl = env('LUA_TRIGGER_URL__ORDER_CREATED')?.trim(); if (!triggerUrl) { return { ok: false, message: 'No injected trigger URL — was this agent installed from a template?' }; } return registerProviderWebhook(triggerUrl); // your provider API call } } ``` The `LUA_TRIGGER_URL__` prefix is **reserved**: a template's env contract cannot declare keys under it (publishing rejects them), and a hand-set variable of the same name is overwritten with the platform's URL at install. The full pattern — pairing the injected URL with a post-install setup turn — is in [Publishing Templates](/marketplace/publishing-templates#self-wiring-webhooks). ## Complete Example: GitHub Pull Request Trigger A production-shaped trigger: HMAC signature verification over the raw bytes, a filter that only lets through PRs assigned to a configured user, and a compact transform (a raw GitHub PR payload is enormous and mostly noise for the agent). ```typescript theme={null} import { defineTrigger, env } from 'lua-cli'; import { createHmac, timingSafeEqual } from 'crypto'; interface GitHubPullRequestEvent { action?: string; assignee?: { login?: string }; pull_request?: { number?: number; title?: string; html_url?: string; }; repository?: { full_name?: string }; } /** Constant-time comparison; length mismatch short-circuits (length is not secret). */ function safeEqual(expected: string, provided: string): boolean { const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(provided, 'utf8'); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } export default defineTrigger({ name: 'github-pr-assigned', description: 'Wakes the agent when a GitHub PR is assigned to the configured user', source: 'webhook', // GitHub signs the exact wire bytes → verify against ctx.rawBody verify: (ctx) => { const secret = env('GITHUB_WEBHOOK_SECRET'); const signature = ctx.headers['x-hub-signature-256']; if (!secret || !ctx.rawBody || typeof signature !== 'string') return false; const expected = 'sha256=' + createHmac('sha256', secret).update(ctx.rawBody, 'utf8').digest('hex'); return safeEqual(expected, signature); }, // Only wake the agent when a PR is assigned to our user filter: (ctx) => { const body = ctx.body ?? {}; if (body.action !== 'assigned') return false; const configured = (env('GITHUB_USERNAME') ?? '').trim().toLowerCase(); return !!configured && body.assignee?.login?.toLowerCase() === configured; }, // Hand-pick fields — the raw payload is huge and would just waste the // agent's context (and risk the ~50k default-payload cap) transform: (ctx) => { const pr = ctx.body.pull_request ?? {}; const repo = ctx.body.repository?.full_name ?? 'unknown/unknown'; return [ `PR assigned on GitHub: ${repo}#${pr.number ?? '?'}`, `Title: ${pr.title ?? '(no title)'}`, `URL: ${pr.html_url ?? '(no url)'}`, `Review it now and post a summary of the risk areas.`, ].join('\n'); }, }); ``` Configure the trigger URL and the same secret in your repository's webhook settings (Repository → Settings → Webhooks, content type `application/json`), and set `GITHUB_WEBHOOK_SECRET` and `GITHUB_USERNAME` with [`lua env`](/cli/env-command). ## Minimal Example: Filter Only The smallest useful trigger — no verification, no transform, just a relevance gate. The agent receives the default `[Trigger: …] Payload: …` message. ```typescript theme={null} import { defineTrigger } from 'lua-cli'; export default defineTrigger({ name: 'deployment-finished', description: 'Wakes the agent when a deployment completes', filter: (ctx) => ctx.body?.status === 'succeeded' || ctx.body?.status === 'failed', }); ``` This trigger accepts any POST that has the URL — there is no `verify` slot. Acceptable for internal tooling behind URL secrecy; not acceptable for anything a third party could abuse. Rotate the token immediately if the URL leaks. ## Using with LuaAgent Register triggers on your agent configuration: ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import githubPrAssigned from './triggers/pr-assigned.trigger'; export const agent = new LuaAgent({ name: 'my-agent', persona: '...', skills: [...], triggers: [ githubPrAssigned, ], }); ``` On `lua push`, each trigger is compiled, versioned, and deployed like any other primitive, and your `lua.skill.yaml` gains a `triggers:` section tracking the deployed name, ID, and version: ```yaml theme={null} triggers: - name: github-pr-assigned triggerId: 5f4c9a1e-2b7d-4c03-9e88-1a2b3c4d5e6f version: 1.0.1 ``` ## Observability Every delivery — invoked, filtered, rejected, or failed — is recorded as an execution. Inspect them with the CLI: ```bash theme={null} lua triggers logs --trigger github-pr-assigned lua triggers logs --trigger github-pr-assigned --limit 5 --json ``` | Status | Meaning | | --------------------- | ---------------------------------------------------------------------------- | | `accepted` | Slots passed; the agent turn is in flight | | `completed` | The agent turn finished; the response text is stored on the execution | | `failed` | A slot threw, the transform returned nothing, or the agent invocation failed | | `rejected_unverified` | `verify` returned `false` (sender saw 401) | | `skipped_filtered` | `filter` returned `false` (sender saw 200) | | `skipped_inactive` | Trigger was deactivated at delivery time | See the [Triggers Command](/cli/triggers-command) for the full management workflow — listing, creating URL triggers, activating, rotating tokens, and deleting. ## Related APIs Manage triggers from the CLI — list, logs, rotate-token Full request/response control with your own execute function Scheduled and queued background work env('KEY') — secrets for your verify slot ## See Also * [LuaAgent](/api/luaagent) - Registering triggers on your agent * [LuaWebhook](/api/luawebhook) - When you need to own the HTTP response * [Triggers Command](/cli/triggers-command) - CLI management and the paste-anywhere URL workflow # LuaWebhook Source: https://docs.heylua.ai/api/luawebhook HTTP endpoints for receiving external events and integrations ## Overview `LuaWebhook` allows you to create HTTP endpoints that can receive events from external services like Stripe, Shopify, GitHub, or any other webhook-enabled platform. ```typescript theme={null} import { LuaWebhook, User } from 'lua-cli'; const paymentWebhook = new LuaWebhook({ name: 'payment-webhook', description: 'Handle Stripe payment events', execute: async (event) => { const { body } = event; // ⚠️ Webhooks have NO conversational context // MUST provide userId to notify users if (body?.type === 'payment_intent.succeeded') { const customerId = body.data?.object?.metadata?.customerId; if (customerId) { const user = await User.get(customerId); await user.send([{ type: 'text', text: '✅ Payment confirmed!' }]); } } return { received: true }; } }); export default paymentWebhook; ``` **No Conversational Context:** Webhooks execute outside of user conversations. You MUST use `User.get(userId)` with an explicit userId. Always store the user ID in your payment/order metadata. HTTP webhooks for external integrations. Use with LuaAgent. ## Webhooks vs Triggers A `LuaWebhook` gives you an `execute` function and full control of the HTTP response — your code does the work. A [`LuaTrigger`](/api/luatrigger) has **no** `execute` function: it declaratively verifies, filters, and transforms an incoming event, then wakes the agent to do the work itself. If the right reaction to an event is "have the agent handle it", reach for a trigger; if you need a custom response body or code-only side effects, stay here. See [LuaTrigger](/api/luatrigger) for the full comparison. ## Use Cases Stripe, PayPal webhooks for payment processing Shopify, WooCommerce order notifications GitHub, GitLab deployment triggers Any service that sends HTTP webhooks ## User Access in Webhooks | Context | How to Get User | userId Required? | | ------------ | -------------------- | ----------------------------- | | **Webhooks** | `User.get(userId)` | ✅ **YES** - Store in metadata | | **Tools** | `User.get()` | ❌ No - automatic context | | **LuaJob** | `User.get(userId)` | ✅ **YES** - Store in metadata | | **Jobs API** | `jobInstance.user()` | ❌ No - automatic context | **Webhooks are context-less:** They're triggered by external systems, not user conversations. Always store the Lua user ID in your payment/order metadata so you can notify the right user. ## Constructor ### new LuaWebhook(config) Creates a new webhook endpoint. Webhook configuration object ## Configuration Parameters ### Required Fields Unique webhook name **Format**: lowercase, hyphens, underscores **Examples**: `'payment-webhook'`, `'order-update-webhook'` Function that handles incoming webhook events **Signature:** `(event: WebhookEvent) => Promise` ```typescript theme={null} execute: async (event) => { const { query, headers, body } = event; // ... } ``` ### Optional Fields Webhook description for documentation HMAC-SHA256 signing key. When set, every request must carry `x-lua-signature: sha256=` or it is rejected with **401** before `execute` runs. Must be a literal or a build-time-resolvable constant. Rotate by changing the value and re-deploying; set to `''` to turn verification off. See [Verify Requests](/overview/webhooks#verify-requests). ## WebhookEvent Shape Every webhook receives a single object with request details: ```typescript theme={null} interface WebhookEvent { query: Record; headers: Record; body: any; timestamp: string; execution?: { eventId: string; // stable idempotency key — survives retries & redeliveries executionId: string; // unique per attempt attempt: number; // 1-based attempt counter }; } ``` Destructure the pieces you need inside your handler:\ `const { query, headers, body, timestamp } = event;` `execution` is present only when your handler is invoked via a durably-delivered [platform event](#event-subscriptions). It is `undefined` for synchronous requests sent directly to your webhook URL and during local `lua test` / `lua dev` runs. See [Delivery Semantics](#delivery-semantics). ## Complete Examples ### Stripe Payment Webhook ```typescript theme={null} import { LuaWebhook, env, Orders, User } from 'lua-cli'; const stripeWebhook = new LuaWebhook({ name: 'stripe-payment-webhook', description: 'Handle Stripe payment events', execute: async (event) => { const { body } = event; console.log('Stripe event:', body?.type); switch (body?.type) { case 'payment_intent.succeeded': const paymentIntent = body.data?.object; // Update order status const order = await Orders.getById(paymentIntent.metadata.orderId); if (order) { await order.updateStatus('CONFIRMED'); // Get user by ID from payment metadata const customerId = paymentIntent.metadata.customerId; const user = await User.get(customerId); // Notify the specific user await user.send([{ type: 'text', text: `✅ Payment confirmed! Order #${order.id} is being processed. Amount: $${paymentIntent.amount/100}` }]); } return { success: true, orderId: order?.id }; case 'payment_intent.payment_failed': const failed = body.data?.object; console.error('Payment failed:', failed); // Notify user of failure const failedCustomerId = failed.metadata.customerId; if (failedCustomerId) { const user = await User.get(failedCustomerId); await user.send([{ type: 'text', text: `❌ Payment failed: ${failed.last_payment_error?.message}. Please try again.` }]); } return { success: false, reason: failed.last_payment_error?.message }; default: console.log('Unhandled event type:', body?.type); return { received: true }; } } }); export default stripeWebhook; ``` **Important:** Always store the user ID (customerId) in your payment metadata so webhooks can notify the correct user. Example: `metadata: { customerId: user.id, orderId: order.id }` ### Shopify Order Webhook ```typescript theme={null} import { LuaWebhook, env, Data, User } from 'lua-cli'; const shopifyOrderWebhook = new LuaWebhook({ name: 'shopify-order-webhook', description: 'Handle Shopify order events', execute: async (event) => { const { body } = event; const order = body; // Store order in custom data await Data.create('shopify-orders', { orderId: order.id, orderNumber: order.order_number, customer: order.customer, total: order.total_price, items: order.line_items, status: order.financial_status, customerId: order.customer.id, // Store customer ID createdAt: order.created_at }, `Order #${order.order_number} ${order.customer.email} ${order.total_price}`); // Notify specific customer using their ID const user = await User.get(order.customer.id); await user.send([{ type: 'text', text: `🛍️ Order #${order.order_number} confirmed!\n\nTotal: $${order.total_price}\nItems: ${order.line_items.length}\n\nWe'll send shipping updates soon!` }]); return { success: true, orderId: order.id, orderNumber: order.order_number, customerId: order.customer.id }; } }); export default shopifyOrderWebhook; ``` **Customer Identification:** Map external customer IDs (Shopify, Stripe) to Lua user IDs. Store this mapping in your order/payment metadata for webhook notifications. ### GitHub Deployment Webhook ```typescript theme={null} import { LuaWebhook, env, User } from 'lua-cli'; const githubWebhook = new LuaWebhook({ name: 'github-deployment-webhook', description: 'Track GitHub deployment events', execute: async (event) => { const { body } = event; const payload = body; if (payload?.action === 'deployment_status') { const status = payload.deployment_status; const deployment = payload.deployment; // Notify on deployment completion // Note: You need to provide userId - webhooks have no conversational context if (status.state === 'success') { // In a real scenario, you'd get userId from deployment metadata // const user = await User.get(userId); // await user.send([{ // type: 'text', // text: `🚀 Deployment successful!\n\nEnvironment: ${deployment.environment}\nRef: ${deployment.ref}\nURL: ${status.target_url}` // }]); console.log('Deployment successful:', deployment.environment); } else if (status.state === 'failure') { console.error('Deployment failed:', deployment.environment); } return { success: true, state: status.state }; } return { received: true }; } }); export default githubWebhook; ``` ### Generic Webhook Template ```typescript theme={null} import { LuaWebhook, env } from 'lua-cli'; const genericWebhook = new LuaWebhook({ name: 'custom-integration-webhook', description: 'Handle events from custom service', execute: async (event) => { const { query, headers, body } = event; try { // Validate event structure if (!body || !body.type) { throw new Error('Invalid event structure'); } // Log event for debugging console.log('Received webhook:', { type: body.type, timestamp: new Date().toISOString(), dataKeys: Object.keys(body.data || {}) }); // Process event based on type switch (body.type) { case 'resource.created': await handleCreate(body.data); break; case 'resource.updated': await handleUpdate(body.data); break; case 'resource.deleted': await handleDelete(body.data); break; default: console.log('Unknown event type:', body.type); } return { success: true, processed: body.type }; } catch (error) { console.error('Webhook processing error:', error); return { success: false, error: error.message, timestamp: new Date().toISOString() }; } } }); async function handleCreate(data: any) { // Handle creation logic console.log('Resource created:', data.id); } async function handleUpdate(data: any) { // Handle update logic console.log('Resource updated:', data.id); } async function handleDelete(data: any) { // Handle deletion logic console.log('Resource deleted:', data.id); } export default genericWebhook; ``` ## Event Subscriptions Webhooks can subscribe to **platform events**: delivery status updates for every outbound channel (WhatsApp, SMS, email, Messenger, Instagram, and the rest). When subscribed, your webhook's `execute` function receives the event payload automatically. ### Available Event Types | Event | Trigger | | ------------------- | ------------------------------------------------------------------------------------ | | `message.sent` | The provider accepted the message | | `message.delivered` | The message reached the recipient (channels that report delivery) | | `message.read` | The recipient read the message (channels that report reads) | | `message.failed` | The message could not be delivered; `error` carries the category and provider detail | Channels without receipts (Slack, Teams, Front, web chat) stop at `message.sent`. ### Subscribing via CLI ```bash theme={null} lua webhooks list-events lua webhooks subscribe --webhook-name my-webhook --event message.delivered lua webhooks subscribe --webhook-name my-webhook --event message.read lua webhooks unsubscribe --webhook-name my-webhook --event message.delivered ``` ### Event Payload Shape When an event fires, your webhook receives a `WebhookEvent` where `body` is the delivery record, the same object `Channels.getStatus` returns: ```typescript theme={null} interface DeliveryEventPayload { id: string; // deliveryId, also returned by Channels.*.send agentId: string; userId?: string; channel: string; // 'whatsapp' | 'sms' | 'email' | 'messenger' | ... provider: string; // 'meta' | 'vonage' | 'bird' | 'ses' | ... channelIdentifier: string; // the sending phone number, inbox, or page recipient: string; providerMessageId?: string; messageWamid?: string; // same as providerMessageId, kept for existing WhatsApp handlers conversationMessageId?: string; origin: 'agent_reply' | 'channels_send' | 'template' | 'system_template' | 'queued_flush' | 'admin_template' | 'notification' | 'unknown'; templateName?: string; idempotencyKey?: string; status: 'queued' | 'accepted' | 'sent' | 'delivered' | 'read' | 'failed' | 'expired'; error?: { category: 'window_closed' | 'unreachable' | 'billing' | 'opted_out' | 'throttled' | 'auth' | 'template' | 'media' | 'invalid_request' | 'compliance' | 'experiment' | 'provider' | 'unknown'; provider: string; code: string; // provider's own code, e.g. Meta 131042 title: string; detail?: string; }; events: Array<{ status: string; at: string; source: 'send' | 'callback' | 'sweep' }>; pricing?: { billable: boolean; category?: string; model?: string }; createdAt: string; updatedAt: string; deliveredAt?: string; readAt?: string; failedAt?: string; } ``` ### Delivery Semantics Subscribed platform events are delivered **at least once**. Each event is durably queued and processed on isolated infrastructure with automatic retries — up to **3 attempts total**, a fixed 60-second wait between attempts, retried only on failure. Your handler may therefore run **more than once** for a single logical event, so keep side effects idempotent. Each invocation receives `event.execution`: | Field | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `eventId` | Stable identifier for the logical event. Unchanged across retries and redeliveries — use it as your idempotency / dedup key. | | `executionId` | Unique identifier for this individual attempt. Not a dedup key. | | `attempt` | 1-based attempt counter (`1` on the first delivery, `2` on the first retry, and so on). | ```typescript theme={null} execute: async (event) => { const { eventId } = event.execution ?? {}; // Key side effects on eventId so a repeated delivery is a no-op await Data.create('delivery-events', { idempotencyKey: eventId, status: event.body.status, }, `${event.body.status} ${eventId}`); return { received: true }; } ``` `event.execution` is only present for durably-delivered platform events — it is `undefined` for synchronous direct requests to your webhook URL and during local `lua test` / `lua dev` runs. Very large event payloads (over 50,000 characters, which is rare) are delivered without the at-least-once retry guarantee. **Direct requests are unchanged.** Requests sent straight to your webhook URL are still handled **synchronously** with no automatic retries — the caller receives your handler's return value and applies its own retry policy. At-least-once delivery and `event.execution` apply only to subscribed platform events. ### Example: Track Delivery for Analytics ```typescript theme={null} import { LuaWebhook, Data } from 'lua-cli'; const analyticsWebhook = new LuaWebhook({ name: 'message-analytics', description: 'Track message delivery for analytics', execute: async (event) => { const { body } = event; await Data.create('message-analytics', { messageId: body.messageWamid, recipient: body.recipientId, status: body.status, channel: body.channel, timestamp: body.timestamp, billable: body.pricing?.billable }, `${body.status} ${body.recipientId}`); return { tracked: true }; } }); export default analyticsWebhook; ``` ## Using with LuaAgent Webhooks are added to your agent configuration: ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import stripeWebhook from './webhooks/stripe'; import shopifyWebhook from './webhooks/shopify'; import githubWebhook from './webhooks/github'; export const agent = new LuaAgent({ name: 'my-agent', persona: '...', skills: [...], webhooks: [ stripeWebhook, shopifyWebhook, githubWebhook ] }); ``` ## Webhook URLs After deploying, your webhooks can be called using either identifier: ``` https://webhook.heylua.ai/{agentId}/{webhookId} // legacy + default https://webhook.heylua.ai/{agentId}/{webhook-name} // friendly alias ``` **Notes:** * `agentId` is your agent identifier (e.g., `agent_abc123`) * `webhookId` is the UUID shown when you create the webhook * `webhook-name` is the `name` you pass to `new LuaWebhook` * You can copy both URLs after pushing: `lua push webhook` **Examples:** ``` https://webhook.heylua.ai/agent_abc123/webhook_01JD3RZ9VX9W5 https://webhook.heylua.ai/agent_abc123/payment-webhook ``` Configure either URL in your external service (Stripe, Shopify, etc.) ## Testing Webhooks ### Local Testing ```bash theme={null} lua test # Select: Webhook → your-webhook-name # Provide test payload ``` ### Test Payloads ```typescript theme={null} // Example test payload for Stripe { "type": "payment_intent.succeeded", "data": { "object": { "id": "pi_test_123", "amount": 2000, "currency": "usd", "metadata": { "orderId": "order_456" } } } } ``` ## Security Best Practices Never hardcode secrets ```typescript theme={null} // ❌ Bad secret: 'hardcoded_secret_key' // ✅ Good secret: env('STRIPE_WEBHOOK_SECRET') ``` Always validate incoming data ```typescript theme={null} execute: async (event) => { const { body } = event; if (!body || !body.type) { throw new Error('Invalid webhook payload'); } // Process webhook... } ``` Webhook handlers should return fast (\< 5 seconds) ```typescript theme={null} // ✅ Queue long-running work execute: async (event) => { const { body } = event; // Quick validation if (!isValid(body)) { return { error: 'Invalid' }; } // Queue processing job await Jobs.create({ execute: async () => { // Long-running work here } }); return { received: true }; } ``` ## Error Handling ```typescript theme={null} const robustWebhook = new LuaWebhook({ name: 'robust-webhook', execute: async (event) => { try { const result = await processWebhook(event); return { success: true, result, timestamp: new Date().toISOString() }; } catch (error) { // Log error for debugging console.error('Webhook error:', { error: error.message, stack: error.stack }); // Return error status (don't throw!) return { success: false, error: error.message, timestamp: new Date().toISOString() }; } } }); ``` ## Invoking an Agent from a Webhook Use `Agents.invoke` to delegate work to a conversational agent after receiving a webhook event. Pass the user ID from the event payload so the invocation runs in that user's context (conversation history is stored). If no user ID is available, omit `userId` and the invocation runs without user identity (no conversation history). ```typescript theme={null} import { LuaWebhook, Agents } from 'lua-cli'; const orderShippedWebhook = new LuaWebhook({ name: 'order-shipped-webhook', description: 'Delegate shipment notifications to the notification agent', execute: async (event) => { const { orderId, customerId, trackingNumber } = event.body ?? {}; if (!customerId) { return { skipped: true, reason: 'no customerId in payload' }; } const result = await Agents.invoke('notification-agent', { prompt: `Order ${orderId} has shipped. Tracking: ${trackingNumber}. Notify the customer.`, userId: customerId, // runs as this user — history is stored }); return { notified: true, agentReply: result.text }; }, }); export default orderShippedWebhook; ``` Full documentation for Agents.invoke — options, output shape, error handling, and more examples ## Common Integrations **Webhook Events:** * `payment_intent.succeeded` * `payment_intent.payment_failed` * `charge.refunded` * `invoice.payment_succeeded` **Secret Location:** Stripe Dashboard → Developers → Webhooks **Webhook Topics:** * `orders/create` * `orders/updated` * `products/create` * `products/delete` **Secret Location:** Shopify Admin → Settings → Notifications **Webhook Events:** * `push` * `pull_request` * `deployment_status` * `release` **Secret Location:** Repository → Settings → Webhooks **Requirements:** * POST request to webhook URL * JSON payload in body * Optional signature verification * Event type in payload or header **Best Practices:** * Include timestamp * Version your payloads * Support replay/retry ## Related APIs Agent configuration Declarative triggers that wake the agent instead of running code Invoke another agent from a webhook Queue long-running work Send notifications Store webhook data ## See Also * [LuaAgent](/api/luaagent) - Adding webhooks to your agent * [LuaTrigger](/api/luatrigger) - Wake the agent on an event, no execute function * [Agents API](/api/agents) - Invoking agents from webhook handlers * [Environment Variables](/api/environment) - Securely managing secrets * [Workflows Concept](/concepts/workflows) # Orders API Source: https://docs.heylua.ai/api/orders Order creation and management ## Overview The Orders API manages order creation, status tracking, and fulfillment. Returns **OrderInstance** objects with direct property access. ```typescript theme={null} import { Orders, OrderStatus } from 'lua-cli'; // Create order - returns OrderInstance const order = await Orders.create({ basketId: 'basket_abc123', data: { shippingAddress, paymentMethod } }); // Direct property access console.log(order.status); // "pending" console.log(order.totalAmount); // 59.98 console.log(order.items); // Array of items // Instance methods await order.updateStatus(OrderStatus.FULFILLED); await order.update({ trackingNumber: 'ABC123' }); await order.save(); // Access updated properties console.log(order.status); // "fulfilled" console.log(order.trackingNumber); // "ABC123" ``` Access `order.status` not `order.common.status` Built-in `updateStatus()`, `update()`, and `save()` methods Properties update after method calls Full TypeScript support ## Order Statuses ```typescript theme={null} import { OrderStatus } from 'lua-cli'; ``` Order created, not yet confirmed Initial status for new orders Order confirmed, being processed Payment successful, ready for fulfillment Order completed and delivered Final status for successful orders Order cancelled by user or system No further processing ## Methods ### create() Create a new order. ```typescript theme={null} Orders.create(orderData: CreateOrderRequest): Promise ``` ID of the basket to convert to order Order details (shipping, payment, etc.) **Returns:** `OrderInstance` with direct property access and methods **Example:** ```typescript theme={null} const order = await Orders.create({ basketId: 'basket_abc123', data: { shippingAddress: { street: '123 Main St', city: 'New York', zip: '10001' }, paymentMethod: 'stripe', customerEmail: 'customer@example.com' } }); // Direct property access console.log(order.id); // "order_def456" console.log(order.status); // "pending" console.log(order.totalAmount); // 59.98 console.log(order.items); // Array of items console.log(order.shippingAddress.city); // "New York" // Instance methods available await order.updateStatus(OrderStatus.CONFIRMED); ``` ### get() Retrieve orders, optionally filtered by status. ```typescript theme={null} Orders.get(status?: OrderStatus): Promise ``` **Examples:** ```typescript theme={null} // Get all orders const allOrders = await Orders.get(); // Get pending orders only const pending = await Orders.get(OrderStatus.PENDING); // Get fulfilled orders const fulfilled = await Orders.get(OrderStatus.FULFILLED); ``` ### getById() Get a specific order by ID. ```typescript theme={null} Orders.getById(orderId: string): Promise ``` **Returns:** `OrderInstance` with direct property access **Example:** ```typescript theme={null} const order = await Orders.getById('order_def456'); // Direct property access (no .common needed!) console.log(order.status); // "fulfilled" console.log(order.totalAmount); // 59.98 console.log(order.items); // Array of items console.log(order.shippingAddress); // Address object // Instance methods await order.updateStatus(OrderStatus.FULFILLED); await order.update({ deliveryDate: '2025-10-15' }); ``` ### updateStatus() Update order status. ```typescript theme={null} Orders.updateStatus(status: OrderStatus, orderId: string): Promise ``` Or use instance method: ```typescript theme={null} order.updateStatus(status: OrderStatus): Promise ``` **Returns:** `OrderInstance` with updated status **Example:** ```typescript theme={null} // Using static method const order = await Orders.updateStatus(OrderStatus.CONFIRMED, orderId); // Or using instance method (recommended) await order.updateStatus(OrderStatus.CONFIRMED); await order.updateStatus(OrderStatus.FULFILLED); await order.updateStatus(OrderStatus.FULFILLED); // Access updated status directly console.log(order.status); // "fulfilled" // Cancel order await order.updateStatus(OrderStatus.CANCELLED); ``` ### updateData() Update order data/metadata. ```typescript theme={null} Orders.updateData(data: Record, orderId: string): Promise ``` **Example:** ```typescript theme={null} await Orders.updateData({ trackingNumber: 'TRACK123456', carrier: 'UPS', estimatedDelivery: '2025-10-10', notes: 'Fragile - handle with care' }, orderId); ``` ### save() (Instance Method) Save the current state of the order to the server. This is a convenience method that persists all changes made to the order instance. ```typescript theme={null} order.save(): Promise ``` **Returns:** Promise resolving to `true` if successful **Example:** ```typescript theme={null} const order = await Orders.getById('order_def456'); // Modify order data properties order.trackingNumber = 'TRACK123456'; order.carrier = 'UPS'; order.estimatedDelivery = '2025-10-10'; // Save all changes at once await order.save(); // Much cleaner workflow! ``` **New in Latest Version:** The `save()` method provides a simpler workflow - modify properties then save, rather than calling `Orders.updateData()` with the order ID. ## OrderInstance All order methods return `OrderInstance` objects with: **Direct Property Access:** ```typescript theme={null} order.id order.status // No .common needed! order.totalAmount // Direct access order.itemCount // Direct access order.items // Array of items order.currency order.shippingAddress // Data property via proxy order.paymentMethod // Data property via proxy order.createdAt // Via proxy (from OrderData) ``` **Instance Methods:** ```typescript theme={null} await order.updateStatus(OrderStatus.FULFILLED); await order.update({ trackingNumber: 'ABC123' }); await order.save(); ``` **Backward Compatible:** ```typescript theme={null} order.status; // ✅ New way order.common.status; // ✅ Old way still works ``` ## Complete Examples ### Create Order Tool ```typescript theme={null} import { LuaTool, Orders } from 'lua-cli'; import { z } from 'zod'; export class CreateOrderTool implements LuaTool { name = "create_order"; description = "Create order from basket"; inputSchema = z.object({ basketId: z.string(), shippingAddress: z.object({ street: z.string(), city: z.string(), zip: z.string() }), paymentMethod: z.string().default('stripe') }); async execute(input: z.infer) { const order = await Orders.create({ basketId: input.basketId, data: { shippingAddress: input.shippingAddress, paymentMethod: input.paymentMethod } }); return { orderId: order.id, status: order.common.status, total: `$${order.common.totalAmount.toFixed(2)}`, message: "Order created successfully!" }; } } ``` ### Track Order Tool ```typescript theme={null} export class TrackOrderTool implements LuaTool { name = "track_order"; description = "Get order status and tracking info"; inputSchema = z.object({ orderId: z.string() }); async execute(input: z.infer) { const order = await Orders.getById(input.orderId); return { orderId: order.id, status: order.common.status, total: `$${order.common.totalAmount.toFixed(2)}`, itemCount: order.common.itemCount, trackingNumber: order.data?.trackingNumber, estimatedDelivery: order.data?.estimatedDelivery, message: this.getStatusMessage(order.common.status) }; } private getStatusMessage(status: string): string { switch (status) { case 'pending': return 'Order is being processed'; case 'confirmed': return 'Order confirmed and being prepared'; case 'fulfilled': return 'Order delivered!'; case 'cancelled': return 'Order was cancelled'; default: return 'Unknown status'; } } } ``` ### Update Order Status Tool ```typescript theme={null} export class UpdateOrderStatusTool implements LuaTool { name = "update_order_status"; description = "Update order fulfillment status"; inputSchema = z.object({ orderId: z.string(), status: z.enum(['pending', 'confirmed', 'fulfilled', 'cancelled']) }); async execute(input: z.infer) { const statusMap = { 'pending': OrderStatus.PENDING, 'confirmed': OrderStatus.CONFIRMED, 'fulfilled': OrderStatus.FULFILLED, 'cancelled': OrderStatus.CANCELLED }; await Orders.updateStatus(statusMap[input.status], input.orderId); return { success: true, message: `Order status updated to ${input.status}` }; } } ``` ## Use Cases ### Order Fulfillment Workflow ```typescript theme={null} // 1. Create order const order = await Orders.create({ basketId, data }); // 2. Confirm payment await Orders.updateStatus(OrderStatus.CONFIRMED, order.id); // 3. Add tracking info await Orders.updateData({ trackingNumber: 'TRACK123', carrier: 'UPS', shippedAt: new Date().toISOString() }, order.id); // 4. Mark as delivered await Orders.updateStatus(OrderStatus.FULFILLED, order.id); ``` ### Customer Service Lookup ```typescript theme={null} // Find customer's orders const userOrders = await Orders.get(); // Filter recent orders const recent = userOrders.filter(order => { const orderDate = new Date(order.createdAt); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); return orderDate > thirtyDaysAgo; }); ``` ### Order Analytics ```typescript theme={null} // Get all orders const orders = await Orders.get(); // Calculate metrics const totalRevenue = orders.reduce((sum, order) => sum + order.common.totalAmount, 0 ); const avgOrderValue = totalRevenue / orders.length; const fulfillmentRate = orders.filter(o => o.common.status === OrderStatus.FULFILLED ).length / orders.length; ``` ## Best Practices ```typescript theme={null} await Orders.updateData({ trackingNumber: 'TRACK123456', carrier: 'UPS', trackingUrl: 'https://track.ups.com/...', estimatedDelivery: '2025-10-10' }, orderId); ``` ```typescript theme={null} async updateOrderStatus(orderId: string, status: OrderStatus) { // Update status await Orders.updateStatus(status, orderId); // Send notification const order = await Orders.getById(orderId); await sendEmail(order.data.customerEmail, { subject: `Order ${orderId} - ${status}`, body: getEmailTemplate(status, order) }); } ``` ```typescript theme={null} async cancelOrder(orderId: string, reason: string) { const order = await Orders.getById(orderId); // Can only cancel if not fulfilled if (order.common.status === OrderStatus.FULFILLED) { throw new Error('Cannot cancel fulfilled order'); } // Update status and add reason await Orders.updateStatus(OrderStatus.CANCELLED, orderId); await Orders.updateData({ cancellationReason: reason, cancelledAt: new Date().toISOString() }, orderId); } ``` ```typescript theme={null} const order = await Orders.getById(orderId); if (!order) { throw new Error(`Order not found: ${orderId}`); } ``` ## Next Steps Create baskets before orders See complete order workflows # API Overview Source: https://docs.heylua.ai/api/overview Complete API reference for building Lua AI skills ## Available APIs Lua CLI provides a comprehensive set of APIs for building AI agents: **LuaAgent, LuaSkill, LuaTool** - Agent configuration and building blocks **LuaJob, LuaWebhook** - Scheduled tasks and HTTP endpoints **PreProcessor, PostProcessor** - Filter and format messages **LuaMCPServer** - Connect MCP servers for external tool integrations **AI, Agents, User, Data, CDN, Products, Baskets, Orders, Jobs, Templates, Channels** - Built-in services **env()** - Secure configuration management LuaAgent, LuaJob, LuaWebhook, PreProcessor, PostProcessor, and Jobs API for comprehensive agent development. ## Installation ```bash npm theme={null} npm install lua-cli zod ``` ```bash yarn theme={null} yarn add lua-cli zod ``` ```bash pnpm theme={null} pnpm add lua-cli zod ``` `zod` is a peer dependency required for schema validation ## Quick Start ### Import APIs ```typescript theme={null} // Core classes import { LuaAgent, LuaSkill, LuaTool, LuaWebhook, LuaJob, LuaMCPServer, PreProcessor, PostProcessor } from 'lua-cli'; // Platform APIs import { AI, Agents, User, Data, CDN, Products, Baskets, Orders, Jobs, Templates, Channels } from 'lua-cli'; // Utilities import { env } from 'lua-cli'; // Type definitions import { BasketStatus, OrderStatus } from 'lua-cli'; // Schema validation import { z } from 'zod'; ``` ### Create an Agent ```typescript theme={null} import { LuaAgent, LuaSkill, LuaTool } from 'lua-cli'; import { z } from 'zod'; // Define a tool class MyTool implements LuaTool { name = "my_tool"; description = "What the tool does"; inputSchema = z.object({ param: z.string() }); async execute(input: any) { return { result: "success" }; } } // Create a skill const skill = new LuaSkill({ name: "my-skill", description: "My custom skill", context: "When to use this skill and its tools", tools: [new MyTool()] }); // Create agent export const agent = new LuaAgent({ name: "my-agent", persona: "You are a helpful assistant...", skills: [skill] }); ``` ## Core Classes ### Agent Configuration Unified agent configuration ```typescript theme={null} new LuaAgent({ name: "my-agent", persona: "...", skills: [...], webhooks: [...], jobs: [...] }) ``` ### Building Blocks Collection of related tools ```typescript theme={null} new LuaSkill({ name: "skill-name", tools: [...] }) ``` Individual function the AI can call ```typescript theme={null} class MyTool implements LuaTool { name = "tool_name" execute(input) { ... } } ``` ### Automation HTTP endpoints for external events ```typescript theme={null} new LuaWebhook({ name: "payment-webhook", execute: async (event) => { ... } }) ``` Scheduled tasks ```typescript theme={null} new LuaJob({ name: "daily-report", schedule: { type: "cron", pattern: "0 9 * * *" }, execute: async (job) => { ... } }) ``` ### Message Processing Filter messages before agent ```typescript theme={null} new PreProcessor({ name: "profanity-filter", execute: async (message, user) => { ... } }) ``` Format responses after agent ```typescript theme={null} new PostProcessor({ name: "add-disclaimer", execute: async (user, message, response, channel) => { ... } }) ``` ## Platform APIs ### AI API **New!** Generate AI responses with custom context in your tools: ```typescript theme={null} import { AI } from 'lua-cli'; const response = await AI.generate( 'You are a helpful sales assistant.', [{ type: 'text', text: 'What products do you recommend?' }] ); ``` **Use cases:** * Product descriptions * Image analysis * Content summarization * Translation * Recommendations * Document Q\&A ### Agents API Invoke the current agent or another agent from within your tool, webhook, job, or processor. Use `Agents.invoke` inside Lua runtime code; `/chat/generate` and `/chat/stream` are for external clients consuming an agent: ```typescript theme={null} import { Agents } from 'lua-cli'; // Simplified — returns plain text const reply = await Agents.invoke('support-agent', 'What is the refund policy?'); // Full options — returns structured output const result = await Agents.invoke('legal-agent', { prompt: 'Review this clause.', threadId: 'contract-456', }); console.log(result.text, result.usage); ``` **Use cases:** * Route requests to specialist agents * Build multi-agent pipelines * Run the current agent from a scheduled job * Delegate from webhooks/jobs to conversational agents ### User API Access user information: ```typescript theme={null} import { User } from 'lua-cli'; // Get current user (from conversation context) const user = await User.get(); // Get specific user by ID (useful in webhooks/jobs) const specificUser = await User.get(userId); // Direct property access console.log(user.name); console.log(user.email); ``` **Use cases:** * Get current user in tools * Get specific user in webhooks (payment notifications) * Send messages to specific users in jobs * Update user profiles ### Data API Custom data storage with vector search: ```typescript theme={null} import { Data } from 'lua-cli'; // Create await Data.create('collection', data, searchText); // Search semantically const results = await Data.search('collection', 'query', 10, 0.7); // Get/Update/Delete await Data.get('collection', filter); // Declare indexes for fields you filter on (large collections) await Data.create('collection', data, { index: ['field'] }); await Data.update('collection', id, data); await Data.delete('collection', id); ``` ### CDN API Upload and retrieve files from the CDN: ```typescript theme={null} import { CDN } from 'lua-cli'; // Upload a file const file = new File([buffer], 'image.png', { type: 'image/png' }); const fileId = await CDN.upload(file); // Get file by ID const retrievedFile = await CDN.get(fileId); console.log(retrievedFile.name, retrievedFile.type); ``` **Use cases:** * Store user uploads * Image storage for AI analysis * Document management * Asset management ### Products API E-commerce product catalog: ```typescript theme={null} import { Products } from 'lua-cli'; // Search const products = await Products.search('laptop'); // CRUD await Products.create({ name, price }); await Products.update({ price }, id); await Products.getById(id); await Products.delete(id); ``` ### Baskets API Shopping cart management: ```typescript theme={null} import { Baskets, BasketStatus } from 'lua-cli'; // Create basket const basket = await Baskets.create({ currency: 'USD' }); // Add items await Baskets.addItem(basket.id, { id: productId, price: 29.99, quantity: 2 }); // Checkout const order = await Baskets.placeOrder(orderData, basket.id); ``` ### Orders API Order processing and tracking: ```typescript theme={null} import { Orders, OrderStatus } from 'lua-cli'; // Create order const order = await Orders.create({ basketId, data: { shippingAddress, paymentMethod } }); // Update status await Orders.updateStatus(OrderStatus.FULFILLED, orderId); ``` ### Jobs API Dynamically create scheduled tasks from tools: ```typescript theme={null} import { Jobs } from 'lua-cli'; // Create a reminder job const job = await Jobs.create({ name: 'user-reminder', metadata: { message: 'Meeting in 10 minutes' }, schedule: { type: 'once', executeAt: new Date(Date.now() + 600000) }, execute: async (jobInstance) => { const user = await jobInstance.user(); await user.send([{ type: 'text', text: jobInstance.metadata.message }]); } }); ``` ### Templates API Send template messages across different channels: ```typescript theme={null} import { Templates } from 'lua-cli'; // List WhatsApp templates const result = await Templates.whatsapp.list(channelId); // Send template message await Templates.whatsapp.send(channelId, templateId, { phoneNumbers: ['+447551166594'], values: { body: { name: 'John', order_number: '12345' } } }); ``` **Supported template types:** * `Templates.whatsapp` - WhatsApp Business templates ### Channels API Send outbound messages on any connected channel — from tools, jobs, webhooks, and triggers: ```typescript theme={null} import { Channels } from 'lua-cli'; // Free-form message on a connected channel await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Your order has shipped!' }); // Rich email await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Receipt', text: 'Thanks for your order!' }); // Approved WhatsApp template (start or re-open a conversation) await Channels.whatsapp.sendTemplate({ to: { phoneNumber: '+14155552671' }, templateName: 'order_update', languageCode: 'en_US' }); ``` **Use cases:** * Proactive notifications and reminders (with [Jobs](/api/jobs)) * Confirmations from webhooks * Agent-initiated follow-ups ## Environment Variables Secure configuration management: ```typescript theme={null} import { env } from 'lua-cli'; const apiKey = env('EXTERNAL_API_KEY'); const baseUrl = env('API_BASE_URL') || 'https://default.com'; ``` ## Type Definitions All APIs are fully typed for TypeScript: ```typescript theme={null} import { LuaSkill, LuaTool, BasketStatus, OrderStatus } from 'lua-cli'; // Autocomplete and type checking work everywhere const user = await User.get(); // user is typed const products = await Products.get(); // products is typed ``` ## Common Patterns ### External API Integration ```typescript theme={null} export class WeatherTool implements LuaTool { name = "get_weather"; description = "Get current weather"; inputSchema = z.object({ city: z.string() }); async execute(input: any) { const response = await fetch( `https://api.weather.com?city=${input.city}` ); return await response.json(); } } ``` ### Using Platform APIs ```typescript theme={null} export class ShoppingTool implements LuaTool { async execute(input: any) { // Search products const products = await Products.search(input.query); // Create basket const basket = await Baskets.create({ currency: 'USD' }); // Add item await Baskets.addItem(basket.id, { id: products.products[0].id, price: products.products[0].price, quantity: 1 }); return { basketId: basket.id }; } } ``` ### Custom Data with Search ```typescript theme={null} export class CreateNoteTool implements LuaTool { async execute(input: any) { // Create with search indexing const note = await Data.create('notes', { title: input.title, content: input.content }, `${input.title} ${input.content}`); return { noteId: note.id }; } } export class SearchNotesTool implements LuaTool { async execute(input: any) { // Semantic search const results = await Data.search( 'notes', input.query, 10, 0.7 ); return { notes: results.map(entry => ({ id: entry.id, title: entry.title, content: entry.content, relevance: entry.score })) }; } } ``` ## API Reference Pages ### Core Classes Agent configuration Skill class API Tool interface HTTP webhooks Scheduled tasks MCP external tools Message filtering Response formatting Server-side device + trigger definitions Voice agent definition ### Runtime APIs Request context API AI generation API Agent invocation API User data API Custom data API File storage API Products API Baskets API Orders API Jobs API Template messaging Outbound messaging Approval and notice cards ### Voice Define voices in code — STT, TTS, LLM, plus plugin & realtime engines ### Utilities Environment utilities ## Best Practices All APIs are fully typed. Use TypeScript for: * Autocomplete * Type checking * Better IDE support Always use Zod schemas for input validation: ```typescript theme={null} inputSchema = z.object({ email: z.string().email(), age: z.number().min(0) }); ``` Implement proper error handling: ```typescript theme={null} try { const result = await api.call(); return result; } catch (error) { throw new Error(`Operation failed: ${error.message}`); } ``` Never hardcode secrets: ```typescript theme={null} const apiKey = env('API_KEY'); if (!apiKey) { throw new Error('API_KEY not configured'); } ``` ## Next Steps Learn about [LuaSkill](/api/luaskill) and [LuaTool](/api/luatool) Explore [User](/api/user), [Data](/api/data), [Products](/api/products), etc. Check out [Tool Examples](/examples/overview) for working code Follow the [First Skill tutorial](/getting-started/first-skill) # PostProcessor Source: https://docs.heylua.ai/api/postprocessor Transform and format AI responses before sending to users ## Overview `PostProcessor` allows you to modify or enhance AI-generated responses before they're sent to users. Use postprocessors to add disclaimers, format output, inject dynamic content, or apply branding. ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const addDisclaimer = new PostProcessor({ name: 'add-disclaimer', description: 'Add legal disclaimer to responses', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { return { modifiedResponse: response + "\n\n_Disclaimer: This is AI-generated content for informational purposes only._" }; } }); export default addDisclaimer; ``` Response postprocessing for formatting, branding, and enhancement. Use with LuaAgent. **Streaming Support:** PostProcessors now execute on both streaming and non-streaming requests. For streaming, post-processors run after the stream completes and emit a `postprocess-complete` event. See [Channel Compatibility](/overview/postprocessors#channel-compatibility) for details. ## Use Cases Add legal or informational disclaimers Apply consistent formatting and styling Add company branding or signatures Inject user-specific information ## Constructor ### new PostProcessor(config) Creates a new response postprocessor. Postprocessor configuration object ## Configuration Parameters ### Required Fields Postprocessor description for documentation Function that processes AI responses **Signature:** `(user: UserDataInstance, message: string, response: string, channel: string) => Promise` **Parameters:** * `user` - User data instance with profile and custom data * `message` - Original user message (string) * `response` - AI-generated response to modify * `channel` - Channel identifier (e.g., 'whatsapp', 'web', 'api') **Recommended:** Use the [Lua Runtime API](/api/lua) instead of the function parameters: * **User:** `User.get()` to retrieve the current user * **Channel:** `Lua.request.channel` for the current channel * **Webhook:** `Lua.request.webhook?.payload` for raw webhook data (WhatsApp, Slack, Teams, etc.) The function parameters may be removed in a future version. ### Optional Fields Unique postprocessor name. Defaults to `'unnamed-postprocessor'` if not provided. **Examples**: `'add-disclaimer'`, `'format-response'` Execution priority. **Lower numbers run first.** Use this to control the order when you have multiple postprocessors (e.g. translate before adding disclaimers). When `true`, the postprocessor runs **asynchronously** — the agent's response is sent to the user immediately and the postprocessor runs in the background. Use for non-blocking side effects (analytics, audit logs) where the user shouldn't wait. When `false` (default), the response is held until the postprocessor returns, allowing it to mutate the text before delivery. ## PostProcessorResponse Your execute function must return: ```typescript theme={null} interface PostProcessorResponse { // Modified response text (required) modifiedResponse: string; } ``` The response object only contains `modifiedResponse`. The modified response becomes the input for the next postprocessor in the chain. ## Complete Examples ### Add Legal Disclaimer ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const legalDisclaimer = new PostProcessor({ name: 'legal-disclaimer', description: 'Add legal disclaimer to medical advice', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { // Check if response contains medical information const medicalKeywords = ['symptom', 'diagnosis', 'treatment', 'medication', 'doctor']; const hasMedicalContent = medicalKeywords.some(keyword => response.toLowerCase().includes(keyword) ); if (hasMedicalContent) { return { modifiedResponse: response + "\n\n⚠️ **Medical Disclaimer:** This information is for educational purposes only and should not be considered medical advice. Please consult with a qualified healthcare professional for medical concerns." }; } return { modifiedResponse: response }; } }); export default legalDisclaimer; ``` ### Add Company Branding ```typescript theme={null} import { PostProcessor, UserDataInstance, env } from 'lua-cli'; const brandingFooter = new PostProcessor({ name: 'branding-footer', description: 'Add company branding to responses', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { const companyName = env('COMPANY_NAME') || 'Our Company'; const supportEmail = env('SUPPORT_EMAIL') || 'support@example.com'; const footer = `\n\n---\n` + `_Powered by ${companyName}_\n` + `Need help? Contact us at ${supportEmail}`; return { modifiedResponse: response + footer }; } }); export default brandingFooter; ``` ### Format Response ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const responseFormatter = new PostProcessor({ name: 'response-formatter', description: 'Apply consistent formatting to responses', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { let formatted = response; // Capitalize first letter of sentences formatted = formatted.replace(/(^|\. )(\w)/g, (match, p1, p2) => { return p1 + p2.toUpperCase(); }); // Add proper spacing after punctuation formatted = formatted.replace(/([.!?])(\w)/g, '$1 $2'); // Format numbers with commas formatted = formatted.replace(/\b(\d{1,3}(?:,?\d{3})*)\b/g, (match) => { return match.replace(/,/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ','); }); // Highlight important terms (example) const importantTerms = ['important', 'urgent', 'required', 'deadline']; importantTerms.forEach(term => { const regex = new RegExp(`\\b(${term})\\b`, 'gi'); formatted = formatted.replace(regex, '**$1**'); }); return { modifiedResponse: formatted }; } }); export default responseFormatter; ``` ### Add User-Specific Context ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const personalizer = new PostProcessor({ name: 'personalizer', description: 'Add personalized greeting and context', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { const userName = user.name || 'there'; const userData = await user.data; const isVIP = userData.vipStatus === true; // Add personalized greeting let personalized = `Hi ${userName}! ${response}`; // Add VIP note if applicable if (isVIP) { personalized += `\n\n✨ As a VIP member, you have priority support and exclusive benefits.`; } return { modifiedResponse: personalized }; } }); export default personalizer; ``` ### Add Call-to-Action ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const ctaInjector = new PostProcessor({ name: 'cta-injector', description: 'Add contextual call-to-action based on response content', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { let cta = ''; // Product-related response if (response.toLowerCase().includes('product') || response.toLowerCase().includes('price')) { cta = '\n\n📦 [View Our Products](https://example.com/products)'; } // Support-related response else if (response.toLowerCase().includes('issue') || response.toLowerCase().includes('problem')) { cta = '\n\n🎫 [Create Support Ticket](https://example.com/support)'; } // General information else { cta = '\n\n💬 Have more questions? Just ask!'; } return { modifiedResponse: response + cta }; } }); export default ctaInjector; ``` ### Translation Wrapper ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const translator = new PostProcessor({ name: 'translator', description: 'Translate responses based on user language preference', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { const userData = await user.data; const userLanguage = userData.language || 'en'; // Skip if already in user's language if (userLanguage === 'en') { return { modifiedResponse: response }; } // Translate response (using external translation API) try { const translated = await translateText(response, userLanguage); return { modifiedResponse: translated }; } catch (error) { console.error('Translation failed:', error); // Fall back to original return { modifiedResponse: response }; } } }); async function translateText(text: string, targetLanguage: string): Promise { // Implementation using translation API (Google Translate, DeepL, etc.) // This is a placeholder return text; } export default translator; ``` ### Sentiment Adjuster ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const sentimentAdjuster = new PostProcessor({ name: 'sentiment-adjuster', description: 'Adjust tone based on user message content', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { // Check if user message indicates frustration const frustrationKeywords = ['frustrated', 'annoyed', 'angry', 'not working', 'broken']; const isFrustrated = frustrationKeywords.some(keyword => message.toLowerCase().includes(keyword) ); if (isFrustrated) { // Add empathetic phrasing const empathetic = "I understand this can be frustrating. " + response; return { modifiedResponse: empathetic }; } return { modifiedResponse: response }; } }); export default sentimentAdjuster; ``` ### Link Enricher ```typescript theme={null} import { PostProcessor, UserDataInstance } from 'lua-cli'; const linkEnricher = new PostProcessor({ name: 'link-enricher', description: 'Convert plain URLs to formatted links', execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { // Convert URLs to markdown links const urlRegex = /(https?:\/\/[^\s]+)/g; const enriched = response.replace(urlRegex, (url) => { const domain = new URL(url).hostname.replace('www.', ''); return `[${domain}](${url})`; }); // Add tracking parameters to links const trackedLinks = enriched.replace(/\((https?:\/\/[^\)]+)\)/g, (match, url) => { const tracked = `${url}${url.includes('?') ? '&' : '?'}utm_source=ai_agent&utm_medium=chat`; return `(${tracked})`; }); return { modifiedResponse: trackedLinks }; } }); export default linkEnricher; ``` ## Using with LuaAgent Postprocessors are added to your agent configuration: ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import translator from './postprocessors/translator'; import responseFormatter from './postprocessors/formatter'; import legalDisclaimer from './postprocessors/disclaimer'; import brandingFooter from './postprocessors/branding'; export const agent = new LuaAgent({ name: 'my-agent', persona: '...', skills: [...], postProcessors: [ translator, responseFormatter, legalDisclaimer, brandingFooter ] }); ``` Postprocessors execute in order of their **priority** value (lowest first). Priority is set when creating the postprocessor via the API. If not specified, default priority is 100. ## Execution Flow ``` AI Agent Response ↓ PostProcessor 1 (priority: 1) ↓ PostProcessor 2 (priority: 10) ↓ PostProcessor 3 (priority: 90) ↓ PostProcessor 4 (priority: 100) ↓ Final Response to User ``` Each postprocessor receives the output of the previous one in the chain. ## Best Practices Order matters - structure flows logically ```typescript theme={null} priority: 1, // Translation (first) priority: 10, // Formatting priority: 50, // Content injection priority: 100, // Disclaimer/branding (last) ``` Don't alter the core message ```typescript theme={null} // ✅ Add to response return { modifiedResponse: response + "\n\nAdditional info..." }; // ❌ Don't completely replace return { modifiedResponse: "Something completely different" }; ``` Fall back to original response on errors ```typescript theme={null} execute: async (user, message, response, channel) => { try { return { modifiedResponse: processResponse(response) }; } catch (error) { console.error('Postprocessor error:', error); return { modifiedResponse: response }; // Return original } } ``` Postprocessors should be quick (\< 50ms) Avoid heavy computations or external API calls when possible. ## Testing Postprocessors ```bash theme={null} lua test # Select: PostProcessor → your-postprocessor-name # Provide test response ``` ## Common Patterns ### Conditional Processing ```typescript theme={null} execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { const userData = await user.data; // Only apply to certain user types if (userData.accountType === 'enterprise') { return { modifiedResponse: response + "\n\n*Enterprise Support Available 24/7*" }; } return { modifiedResponse: response }; } ``` ### Regex Replacements ```typescript theme={null} execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { // Replace placeholders let modified = response .replace(/{{user_name}}/g, user.name || 'User') .replace(/{{company}}/g, env('COMPANY_NAME')) .replace(/{{date}}/g, new Date().toLocaleDateString()); return { modifiedResponse: modified }; } ``` ### Markdown Formatting ```typescript theme={null} execute: async (user: UserDataInstance, message: string, response: string, channel: string) => { // Add markdown formatting let formatted = response .replace(/\*\*([^*]+)\*\*/g, '**$1**') // Bold .replace(/_([^_]+)_/g, '_$1_') // Italic .replace(/`([^`]+)`/g, '`$1`'); // Code return { modifiedResponse: formatted }; } ``` ## Related APIs Process messages before agent Agent configuration Access user data Store and retrieve data ## See Also * [PreProcessor](/api/preprocessor) - Processing incoming messages * [LuaAgent](/api/luaagent) - Adding postprocessors to your agent * [Workflows Concept](/concepts/workflows) # PreProcessor Source: https://docs.heylua.ai/api/preprocessor Filter and route messages before they reach your AI agent ## Overview `PreProcessor` allows you to intercept and process user messages before they reach your AI agent. Use preprocessors to filter spam, route messages, validate inputs, or modify messages before the agent sees them. ```typescript theme={null} import { PreProcessor } from 'lua-cli'; const profanityFilter = new PreProcessor({ name: 'profanity-filter', description: 'Filter inappropriate content', priority: 10, execute: async (user, messages, channel) => { const hasProfanity = messages.some(msg => msg.type === 'text' && msg.text.includes('badword') ); if (hasProfanity) { // Block message and respond return { action: 'block', response: "Please keep the conversation respectful." }; } // Allow messages to proceed return { action: 'proceed' }; } }); export default profanityFilter; ``` Message preprocessing for filtering, routing, and validation. Use with LuaAgent. ## Use Cases Block spam, profanity, or inappropriate content Route messages to different agents or handlers Validate message format or required information Track message metrics before processing ## Constructor ### new PreProcessor(config) Creates a new message preprocessor. Preprocessor configuration object ## Configuration Parameters ### Required Fields Preprocessor description for documentation Function that processes incoming messages **Signature:** `(user: UserDataInstance, messages: ChatMessage[], channel: string) => Promise` **Arguments:** * `user`: The [UserDataInstance](#userdata-object) representing the current user. * `messages`: Array of [ChatMessage](#message-structure) objects sent by the user. * `channel`: The channel identifier (e.g., `'whatsapp'`, `'web'`, `'api'`). **Recommended:** Use the [Lua Runtime API](/api/lua) instead of the function parameters: * **User:** `User.get()` to retrieve the current user * **Channel:** `Lua.request.channel` for the current channel * **Webhook:** `Lua.request.webhook?.payload` for raw webhook data (WhatsApp, Slack, Teams, etc.) The function parameters may be removed in a future version. ### Optional Fields Unique preprocessor name. Defaults to `'unnamed-preprocessor'` if not provided. **Examples**: `'profanity-filter'`, `'message-router'` Execution priority (lower runs first). Default: `100` Run this preprocessor asynchronously on the server. When `true`, the preprocessor executes in a non-blocking mode. Default: `false` ## UserData Object The `user` argument passed to `execute` provides access to the current user's data and methods. ```typescript theme={null} interface UserDataInstance { // Direct access to user data properties uid: string; name?: string; email?: string; [key: string]: any; // Helper methods getChatHistory(): Promise; // ... other user management methods } ``` ## Message Structure The `messages` array passed to your execute function contains `ChatMessage` objects: ```typescript theme={null} type ChatMessage = TextMessage | ImageMessage | FileMessage; interface TextMessage { type: 'text'; text: string; } interface ImageMessage { type: 'image'; image: string; // URL or base64 mimeType: string; // e.g. "image/png", "image/jpeg" } interface FileMessage { type: 'file'; data: string; // URL or base64 mimeType: string; // e.g. "application/pdf" } ``` ## Return Type Your execute function must return a `PreProcessorBlockResponse` or `PreProcessorProceedResponse` object indicating whether to proceed or block. ### 1. Proceed To allow the message to continue to the next preprocessor or the agent: ```typescript theme={null} // Proceed with original message return { action: 'proceed' }; // OR proceed with a modified message return { action: 'proceed', modifiedMessage: [{ type: 'text', text: 'Modified content' }], metadata: { reason: 'message-modified' } // optional metadata }; ``` ### 2. Block To stop processing immediately and respond to the user: ```typescript theme={null} return { action: 'block', response: "This message was blocked.", metadata: { reason: 'profanity' } // optional metadata }; ``` ## Complete Examples ### Profanity Filter ```typescript theme={null} import { PreProcessor } from 'lua-cli'; const profanityWords = ['badword1', 'badword2', 'badword3']; const profanityFilter = new PreProcessor({ name: 'profanity-filter', description: 'Block messages containing inappropriate language', priority: 10, // Run early execute: async (user, messages, channel) => { // Check all text messages const hasProfanity = messages.some(msg => { if (msg.type === 'text') { return profanityWords.some(word => msg.text.toLowerCase().includes(word)); } return false; }); if (hasProfanity) { console.log(`Blocked profanity from user ${user.uid}`); return { action: 'block', response: "Please keep the conversation respectful. Inappropriate language is not allowed." }; } return { action: 'proceed' }; } }); export default profanityFilter; ``` ### Business Hours Filter ```typescript theme={null} import { PreProcessor } from 'lua-cli'; const businessHoursFilter = new PreProcessor({ name: 'business-hours-filter', description: 'Block messages outside business hours', execute: async (user, messages, channel) => { const now = new Date(); const hour = now.getHours(); const day = now.getDay(); // Business hours: Mon-Fri, 9 AM - 5 PM const isWeekday = day >= 1 && day <= 5; const isBusinessHours = hour >= 9 && hour < 17; if (!isWeekday || !isBusinessHours) { return { action: 'block', response: "Thank you for contacting us. Our business hours are Monday-Friday, 9 AM - 5 PM. We'll respond to your message during our next business day." }; } return { action: 'proceed' }; } }); export default businessHoursFilter; ``` ### Message Enrichment ```typescript theme={null} import { PreProcessor } from 'lua-cli'; const messageEnricher = new PreProcessor({ name: 'message-enricher', description: 'Add context to messages before processing', priority: 50, execute: async (user, messages, channel) => { // Enrich message with user info const enrichedMessages = messages.map(msg => { if (msg.type === 'text') { return { ...msg, text: `${msg.text}\n\n[Context: User ID ${user.uid}]` }; } return msg; }); return { action: 'proceed', modifiedMessage: enrichedMessages }; } }); export default messageEnricher; ``` ## Execution Flow Preprocessors are executed in a **pipeline**: 1. **Sequential Execution**: Preprocessors run one after another, sorted by `priority`. 2. **Modifications**: If a preprocessor modifies a message, the *next* preprocessor receives the modified version. 3. **Blocking**: If any preprocessor returns `action: 'block'`, execution stops immediately, and the response is sent to the user. The agent is not called. ## Testing Preprocessors ```bash theme={null} lua test # Select: PreProcessor → your-preprocessor-name # Provide test message ``` ## See Also * [PostProcessor](/api/postprocessor) - Processing responses * [LuaAgent](/api/luaagent) - Adding preprocessors to your agent # Products API Source: https://docs.heylua.ai/api/products Manage e-commerce product catalog ## Overview The Products API provides complete CRUD operations for managing an e-commerce product catalog. Returns **ProductInstance** objects with direct property access. ```typescript theme={null} import { Products } from 'lua-cli'; // Search products - returns ProductSearchInstance const results = await Products.search('laptop'); // Direct property access on results const names = results.map(p => p.name); const prices = results.map(p => p.price); // Create product - returns ProductInstance const product = await Products.create({ name: 'MacBook Pro', price: 1999.99 }); // Direct property access console.log(product.name); // "MacBook Pro" console.log(product.price); // 1999.99 // Instance methods await product.update({ price: 1799.99 }); await product.save(); await product.delete(); ``` Access properties with `product.name` not `product.data.name` Use `.map()`, `.filter()` on search results Built-in `update()`, `save()`, and `delete()` Use `for...of` loops ## Return Shape Reference **`Products.search()` and `Products.get()` do NOT return arrays with a `.data` property.** They return instance wrappers that are directly iterable. | Method | Returns | How to use | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `Products.search(q, limit?)` | `ProductSearchInstance` — iterable, has `.products`, `.length` | `results.map(p => p.name)`, `results.products`, `results.length` — **no `.data`** | | `Products.get(page?, limit?)` or `Products.get({page, limit, filter})` | `ProductPaginationInstance` — iterable, has `.products`, `.pagination`, `.length` | `results.map(p => p.name)`, `results.pagination.totalPages` — **no `.data`** | | `Products.create(product)` | `ProductInstance` | `product.name`, `product.price`, `await product.update({...})` | | `Products.getById(id)` | `ProductInstance` | Direct property access | | `Products.update(data, id)` | `UpdateProductResponse` | `{ product }` | | `Products.delete(id)` | `DeleteProductResponse` | `{ success }` | **Quick examples:** ```typescript theme={null} // ✅ Products.search → ProductSearchInstance (iterable) const results = await Products.search('laptop'); results.forEach(p => console.log(p.name, p.price)); // iterate directly const names = results.map(p => p.name); // array methods work console.log(results.length); // ✅ count // ❌ results.data → does not exist // ✅ Products.get → ProductPaginationInstance const page = await Products.get({ page: 1, limit: 20, filter: { inStock: true } }); page.map(p => p.name); // iterate directly console.log(page.pagination.totalPages); // ✅ pagination // ❌ page.data → does not exist ``` ## Methods ### search() Search products by name or description using semantic search. ```typescript theme={null} Products.search(query: string): Promise ``` Search query to match against product names and descriptions **Returns:** `ProductSearchInstance` - Array-like collection with direct access to products via `.products` property **Example:** ```typescript theme={null} // Search with default limit (5) const results = await Products.search('laptop'); // Search with custom limit const moreResults = await Products.search('laptop', 10); // Direct array methods results.forEach(product => { console.log(`${product.name} - $${product.price}`); }); // Or use .map() const names = results.map(p => p.name); const prices = results.map(p => p.price); // Filter const affordable = results.filter(p => p.price < 1000); // Find const specific = results.find(p => p.sku === 'LAP-001'); // Check length console.log(`Found ${results.length} products`); // For...of iteration for (const product of results) { console.log(product.name); } ``` ### get() Retrieve products with pagination and optional filtering. ```typescript theme={null} // Simple pagination (backward compatible) Products.get(page?: number, limit?: number): Promise // With filter options (recommended) Products.get(options?: ProductFilterOptions): Promise ``` **ProductFilterOptions:** ```typescript theme={null} interface ProductFilterOptions { page?: number; // Page number (1-indexed), default: 1 limit?: number; // Items per page, default: 10 filter?: object; // Bounded filter for product data } ``` Page number (1-indexed) Number of products per page (maximum 100) Bounded filter object to query product data fields **Returns:** `ProductPaginationInstance` - Array-like collection with pagination support **Examples:** ```typescript theme={null} // Simple pagination (backward compatible) const page = await Products.get(1, 20); // Using options object (recommended) const page = await Products.get({ page: 1, limit: 20 }); // With filters - exact match const electronics = await Products.get({ page: 1, limit: 20, filter: { category: 'Electronics' } }); // With filters - price range const affordable = await Products.get({ filter: { price: { $lte: 100 } } }); // With filters - multiple conditions const inStockLaptops = await Products.get({ filter: { category: 'Laptops', inStock: true, price: { $gte: 500, $lte: 1500 } } }); // Direct array methods page.forEach(product => { console.log(`${product.name} - $${product.price}`); }); // Access pagination info console.log(`Page ${page.pagination.currentPage} of ${page.pagination.totalPages}`); console.log(`Showing ${page.length} of ${page.pagination.totalCount} products`); // Navigate pages if (page.pagination.hasNextPage) { const nextPage = await page.nextPage(); } if (page.pagination.hasPrevPage) { const prevPage = await page.prevPage(); } // Use array methods const names = page.map(p => p.name); const inStock = page.filter(p => p.inStock); ``` **Filter Operators:** Product filters use the platform-wide [Lua Query language](/api/query). The grammar, validation, limits, and errors are identical to Data and every other API that accepts a `filter`. ```typescript theme={null} // Comparison { price: { $eq: 99.99 } } // Equal { price: { $ne: 99.99 } } // Not equal { price: { $gt: 50 } } // Greater than { price: { $gte: 50 } } // Greater than or equal { price: { $lt: 100 } } // Less than { price: { $lte: 100 } } // Less than or equal // Array matching { category: { $in: ['Electronics', 'Computers'] } } { category: { $nin: ['Discontinued'] } } { category: ['Electronics', 'Computers'] } // Shorthand for $in // Existence { inventory: { $exists: true } } // Logical { $and: [{ inStock: true }, { price: { $lte: 100 } }] } { $or: [{ category: 'Computers' }, { category: 'Tablets' }] } // Nested fields (dot notation) { 'specs.color': 'black' } { 'metadata.brand': 'Apple' } ``` See [Lua Query security, errors, and resource limits](/api/query#security-and-errors) for the common contract. ### getById() Get a specific product by ID. ```typescript theme={null} Products.getById(id: string): Promise ``` **Returns:** `ProductInstance` with direct property access and methods **Example:** ```typescript theme={null} const product = await Products.getById('product_abc123'); // Direct property access console.log(product.name); // "MacBook Pro" console.log(product.price); // 1999.99 console.log(product.sku); // "MBP-14" // Instance methods await product.update({ price: 1799.99 }); await product.delete(); ``` ### create() Create a new product. ```typescript theme={null} Products.create(product: Product): Promise ``` Product name Product price Product category Stock keeping unit Whether product is in stock Product description **Returns:** `ProductInstance` with direct property access and methods **Example:** ```typescript theme={null} const product = await Products.create({ name: 'Wireless Mouse', price: 29.99, category: 'Electronics', sku: 'MOUSE-001', inStock: true, description: 'Ergonomic wireless mouse' }); // Direct property access console.log(product.id); // "product_xyz789" console.log(product.name); // "Wireless Mouse" console.log(product.price); // 29.99 // Instance methods available await product.update({ price: 24.99 }); ``` ### update() (Instance Method) Update an existing product via an instance. Use `product.update()` on a retrieved product — there is no static `Products.update()` method. ```typescript theme={null} product.update(data: Record): Promise ``` Partial product data to update **Example:** ```typescript theme={null} const product = await Products.getById('product_xyz789'); // Update via instance method await product.update({ price: 24.99, inStock: false }); // Access updated properties console.log(product.price); // 24.99 console.log(product.inStock); // false ``` ### save() (Instance Method) Save the current state of the product to the server. This is a convenience method that persists all changes made to the product instance. ```typescript theme={null} product.save(): Promise ``` **Returns:** Promise resolving to `true` if successful **Example:** ```typescript theme={null} const product = await Products.getById('product_xyz789'); // Modify product properties directly product.price = 24.99; product.inStock = false; product.description = "Updated description"; // Save all changes at once await product.save(); // Much cleaner workflow! ``` **New in Latest Version:** The `save()` method provides a simpler workflow - modify properties then save, rather than calling `Products.update()` with the product ID. ### delete() Delete a product. ```typescript theme={null} Products.delete(id: string): Promise ``` **Example:** ```typescript theme={null} await Products.delete('product_xyz789'); ``` ## ProductInstance All product methods return `ProductInstance` objects with: **Direct Property Access:** ```typescript theme={null} product.name product.price product.sku product.inStock product.category product.description // Any custom fields ``` **Instance Methods:** ```typescript theme={null} await product.update({ price: 999.99 }); await product.save(); await product.delete(); ``` **Backward Compatible:** ```typescript theme={null} product.name; // ✅ New way product.data.name; // ✅ Old way still works ``` ## Complete Examples ### Search Tool ```typescript theme={null} import { LuaTool, Products } from 'lua-cli'; import { z } from 'zod'; export class SearchProductsTool implements LuaTool { name = "search_products"; description = "Search for products by name or description"; inputSchema = z.object({ query: z.string().describe("Search query"), maxPrice: z.number().optional() }); async execute(input: z.infer) { const results = await Products.search(input.query); // Filter by price if specified let products = results.products; if (input.maxPrice) { products = products.filter(p => p.price <= input.maxPrice); } return { products: products.map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, inStock: p.inStock ? '✅ In Stock' : '❌ Out of Stock' })), total: products.length }; } } ``` ### Create Product Tool ```typescript theme={null} export class CreateProductTool implements LuaTool { name = "create_product"; description = "Add a new product to the catalog"; inputSchema = z.object({ name: z.string(), price: z.number().positive(), category: z.string().optional(), sku: z.string().optional(), description: z.string().optional() }); async execute(input: z.infer) { const result = await Products.create({ ...input, inStock: true }); return { success: true, productId: result.id, message: `Product "${input.name}" created successfully` }; } } ``` ### Update Stock Tool ```typescript theme={null} export class UpdateStockTool implements LuaTool { name = "update_stock"; description = "Update product stock status"; inputSchema = z.object({ productId: z.string(), inStock: z.boolean() }); async execute(input: z.infer) { await Products.update( { inStock: input.inStock }, input.productId ); return { success: true, message: `Stock status updated to ${input.inStock ? 'in stock' : 'out of stock'}` }; } } ``` ## Use Cases ### E-commerce Catalog ```typescript theme={null} // Browse products with pagination const products = await Products.get({ page: 1, limit: 20 }); // Search specific items (semantic search) const laptops = await Products.search('laptop'); // Filter by category const electronics = await Products.get({ filter: { category: 'Electronics' } }); // Get product details const product = await Products.getById(laptops.products[0].id); ``` ### Inventory Management ```typescript theme={null} // Update product price await Products.update({ price: 899.99 }, productId); // Mark as out of stock await Products.update({ inStock: false }, productId); // Update multiple fields await Products.update({ price: 799.99, inStock: true, category: 'Electronics - Sale' }, productId); // Get all out-of-stock products const outOfStock = await Products.get({ filter: { inStock: false } }); ``` ### Filtering Products ```typescript theme={null} // By price range const affordable = await Products.get({ filter: { price: { $gte: 50, $lte: 200 } } }); // By category and availability const availablePhones = await Products.get({ filter: { category: 'Phones', inStock: true } }); // By nested properties const appleProducts = await Products.get({ filter: { 'metadata.brand': 'Apple' } }); // Multiple categories const gadgets = await Products.get({ filter: { category: { $in: ['Phones', 'Tablets', 'Watches'] } } }); ``` ### Product Recommendations ```typescript theme={null} // Search similar products const similar = await Products.search(product.category); // Or filter by same category const sameCategory = await Products.get({ filter: { category: product.category, price: { $gte: product.price * 0.8, $lte: product.price * 1.2 } } }); ``` ### Search vs Filter **Use `Products.search()`** for semantic/fuzzy matching: * "laptop for students" * "wireless headphones" * Natural language queries ```typescript theme={null} // Finds products by meaning, not exact match const results = await Products.search('affordable gaming laptop'); ``` **Use `Products.get()` with filter** for structured queries: * Exact category matches * Price ranges * Stock status * Attribute combinations ```typescript theme={null} // Finds products matching exact criteria const results = await Products.get({ filter: { category: 'Laptops', price: { $lte: 1000 }, inStock: true } }); ``` ## Best Practices Always check if product exists: ```typescript theme={null} const product = await Products.getById(input.productId); if (!product) { throw new Error(`Product not found: ${input.productId}`); } ``` ```typescript theme={null} const product = await Products.getById(productId); if (!product.inStock) { return { success: false, message: `${product.name} is currently out of stock` }; } ``` ```typescript theme={null} return { products: products.map(p => ({ ...p, price: `$${p.price.toFixed(2)}`, formattedPrice: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(p.price) })) }; ``` ```typescript theme={null} const results = await Products.search(query); if (results.products.length === 0) { return { products: [], message: `No products found for "${query}". Try different search terms.` }; } ``` If product results don't look right, log the raw return to see the actual shape: ```typescript theme={null} const results = await Products.search(input.query); console.log('Products.search result:', JSON.stringify(results, null, 2)); ``` Then run `lua logs --type skill --limit 5` after a test message to inspect the output. See the [Debugging Skills guide](/cli/debugging) for the full workflow. ## Next Steps Add products to shopping carts See complete tool examples Inspect runtime return values # Lua Query Source: https://docs.heylua.ai/api/query The common bounded Mongo-style query language used across Lua platform APIs ## Overview Lua Query is the single filter language for every Lua platform API that exposes a `filter` parameter. It behaves the same for Data, Products, and any other Mongo-backed entity: the entity changes, but the supported syntax, validation rules, limits, and errors do not. API implementations scope the compiled query to the entity's data and immutable ownership fields. Filter input cannot replace an agent, collection, user, or organization predicate. Methods without a `filter` parameter do not interpret their input as Lua Query. For example, `User.get()` selects one record by an exact user and agent identity; custom properties stored on that record remain ordinary data. ## Supported syntax Comparison operands must be JSON scalars. Membership operands must be arrays of JSON scalars. Logical operators are allowed only at the root of a filter or one of its logical branches. ```typescript theme={null} // Equality and comparison { status: 'active' } { age: { $eq: 25 } } { age: { $ne: 25 } } { age: { $gt: 25, $lte: 65 } } // Membership and array shorthand { tags: { $in: ['urgent', 'important'] } } { tags: { $nin: ['spam', 'archived'] } } { tags: ['urgent', 'important'] } // Shorthand for $in // Existence { email: { $exists: true } } // Root logical operators { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] } { $or: [{ status: 'active' }, { status: 'pending' }] } // Nested fields { 'metadata.brand': 'Lua' } { metadata: { brand: 'Lua' } } ``` The complete operator set is `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`, root `$and`, and root `$or`. ## Security and errors Lua Query is deliberately smaller than the complete MongoDB query language. Unknown or dangerous operators—including `$where`, `$expr`, `$function`, `$regex`, `$text`, and geospatial operators—are rejected with a `400` error and a stable `FILTER_*` code. They are never executed, silently removed, or allowed to widen the query. Field paths cannot contain `$` segments, null bytes, empty segments, or prototype-related names. Invalid operands and misplaced operators also fail closed. ```text theme={null} API request failed with status 400 (FILTER_UNSUPPORTED_OPERATOR at $.name.$regex): Invalid filter: $regex is not supported ``` ## Resource limits The same limits apply on every API surface: | Limit | Maximum | | ------------------------------- | ------------: | | Encoded filter size | 8 KiB | | Nesting depth | 8 levels | | Total nodes | 128 | | Branches per logical operator | 20 | | Values per `$in` or `$nin` list | 100 | | Field path size | 256 bytes | | String operand size | 4 KiB | | Page size | 100 items | | Pagination offset | 100,000 items | | Database execution time | 5 seconds | These limits protect shared database capacity while keeping common application queries expressive and predictable. # Templates API Source: https://docs.heylua.ai/api/templates Send template messages programmatically ## Overview The Templates API allows you to list, retrieve, and send template messages programmatically. Templates are pre-approved message formats for proactive communication across different channels. Not to be confused with **[Agent Templates](/marketplace/agent-templates)** — full agent blueprints published and installed via `lua marketplace template`. This page is the runtime API for *message* templates. **Sending a template as part of a conversation?** [`Channels.whatsapp.sendTemplate`](/api/channels#channels-whatsapp-sendtemplate) is the canonical way to send a WhatsApp template to a specific user — it resolves the recipient by `userId` or phone number and records the send to the conversation thread, so your agent remembers the outreach. Use the `Templates.whatsapp.send` below for **bulk / campaign** sends by channel ID and a list of phone numbers. Both are supported; pick by use case. ## Supported Template Types Pre-approved message formats for WhatsApp Business Accounts. Required for initiating conversations outside the 24-hour messaging window. Additional template types may be added in the future. The API is designed to work consistently across different template types. ## Quick Start ```typescript theme={null} import { Templates } from 'lua-cli'; // List all WhatsApp templates for a channel const result = await Templates.whatsapp.list(channelId); console.log(result.templates); // Array of templates console.log(result.total); // Total count // Get a specific template const template = await Templates.whatsapp.get(channelId, 'template_123'); console.log(template.name); // Template name console.log(template.status); // 'APPROVED', 'PENDING', etc. // Send a WhatsApp template message const response = await Templates.whatsapp.send(channelId, 'template_123', { phoneNumbers: ['+447551166594'], values: { body: { first_name: 'John', order_number: '12345' } } }); ``` List and search templates with pagination Get template details including components Send to multiple recipients at once Fill template parameters dynamically ## WhatsApp Templates Use `Templates.whatsapp` for WhatsApp Business templates. ### list() List WhatsApp templates for a channel with optional pagination and search. ```typescript theme={null} Templates.whatsapp.list( channelId: string, options?: ListTemplatesOptions ): Promise ``` The WhatsApp channel identifier Optional pagination and search options Page number (1-indexed) Items per page Search query to filter templates by name **Returns:** ```typescript theme={null} { templates: Template[]; total: number; page: number; limit: number; totalPages: number; } ``` **Examples:** ```typescript theme={null} // List all templates (first page) const result = await Templates.whatsapp.list(channelId); // With pagination const page2 = await Templates.whatsapp.list(channelId, { page: 2, limit: 20 }); // Search by name const orderTemplates = await Templates.whatsapp.list(channelId, { search: 'order' }); // Combine options const filtered = await Templates.whatsapp.list(channelId, { page: 1, limit: 5, search: 'welcome' }); ``` ### get() Retrieve a specific WhatsApp template by ID. ```typescript theme={null} Templates.whatsapp.get( channelId: string, templateId: string ): Promise ``` The WhatsApp channel identifier The template identifier **Returns:** `WhatsAppTemplate` object **Example:** ```typescript theme={null} const template = await Templates.whatsapp.get(channelId, 'template_123'); console.log(template.name); // "order_confirmation" console.log(template.status); // "APPROVED" console.log(template.category); // "UTILITY" console.log(template.language); // "en" console.log(template.components); // Array of components ``` ### send() Send a WhatsApp template message to one or more phone numbers. ```typescript theme={null} Templates.whatsapp.send( channelId: string, templateId: string, data: SendTemplateData ): Promise ``` The WhatsApp channel identifier The template identifier Send data including recipients and template values Array of recipients. For WhatsApp: phone numbers in E.164 format (e.g., +447551166594) Template parameter values Header component parameter values. For media headers, either use `image_url` (IMAGE format), `video_url` (VIDEO format), or `document_url` + optional `document_filename` (DOCUMENT format) — or pass a Meta media ID instead via `image_id`, `video_id`, or `document_id`. A media ID takes precedence if both are given. Body component parameter values Array of button parameter values. Each entry has `sub_type` (`'QUICK_REPLY'` | `'URL'` | `'PHONE_NUMBER'` | `'COPY_CODE'`), `index`, and optional `text` or `coupon_code`. **Returns:** ```typescript theme={null} { results: Array<{ phoneNumber: string; success: boolean; messageId?: string; }>; errors: Array<{ phoneNumber: string; error: string; }>; } ``` **Examples:** ```typescript theme={null} // Simple send to one recipient const result = await Templates.whatsapp.send(channelId, 'welcome_template', { phoneNumbers: ['+447551166594'] }); // Send with body parameters const orderResult = await Templates.whatsapp.send(channelId, 'order_confirmation', { phoneNumbers: ['+447551166594'], values: { body: { first_name: 'John', order_number: '12345', delivery_date: 'December 25, 2025' } } }); // Send to multiple recipients (batch) const batchResult = await Templates.whatsapp.send(channelId, 'promotion', { phoneNumbers: ['+447551166594', '+447551166595', '+447551166596'], values: { header: { image_url: 'https://example.com/promo.jpg' }, body: { discount: '20%' } } }); // Send with video header const videoResult = await Templates.whatsapp.send(channelId, 'product_demo', { phoneNumbers: ['+447551166594'], values: { header: { video_url: 'https://example.com/product-demo.mp4' }, body: { product_name: 'Acme Widget' } } }); // Send with document header const docResult = await Templates.whatsapp.send(channelId, 'invoice', { phoneNumbers: ['+447551166594'], values: { header: { document_url: 'https://example.com/invoice-12345.pdf', document_filename: 'invoice-12345.pdf' }, body: { order_number: '12345', total: '$99.99' } } }); // Handle response batchResult.results.forEach(r => { if (r.success) { console.log(`Sent to ${r.phoneNumber}: ${r.messageId}`); } }); batchResult.errors.forEach(e => { console.error(`Failed for ${e.phoneNumber}: ${e.error}`); }); ``` ## Template Structure ### WhatsApp Templates WhatsApp templates have the following structure: ```typescript theme={null} interface Template { id: string; name: string; status: 'APPROVED' | 'PENDING' | 'REJECTED'; category: 'UTILITY' | 'MARKETING' | 'AUTHENTICATION'; language: string; components: TemplateComponent[]; } interface TemplateComponent { type: 'HEADER' | 'BODY' | 'FOOTER' | 'BUTTONS'; format?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; text?: string; buttons?: TemplateButton[]; } interface TemplateButton { type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE'; text: string; url?: string; phone_number?: string; example?: string[]; } ``` Template structure may vary by template type. The above shows the WhatsApp template structure. ### Media Headers WhatsApp templates support media headers in addition to text headers. Headers can contain an image, video, or document: * **IMAGE**: Static image (`format: 'IMAGE'`) * **VIDEO**: Playable video clip (`format: 'VIDEO'`) * **DOCUMENT**: Downloadable document like a PDF (`format: 'DOCUMENT'`) When creating a template with a media header, you provide: * **`format`** — one of `'IMAGE'`, `'VIDEO'`, or `'DOCUMENT'` * **`mediaUrl`** — a publicly-reachable HTTPS URL to a sample file for approval. This URL must: * Be accessible over HTTPS * Match the format's MIME type requirements: * IMAGE: `.jpg`, `.png`, `.webp` * VIDEO: `.mp4`, `.3gp` * DOCUMENT: `.pdf`, `.docx`, `.xlsx`, `.pptx` (up to 10 MB) * Be within Meta's size limits (images ≤ 5 MB, videos ≤ 16 MB, documents ≤ 10 MB) Once the template is approved, when you send it via the `Templates.whatsapp.send()` API, you provide the actual media URL in the `values.header` object: * `image_url` for IMAGE format templates * `video_url` for VIDEO format templates * `document_url` (and optionally `document_filename`) for DOCUMENT format templates The media URL provided at send time must also be publicly reachable over HTTPS. ### Sending by media ID instead of URL WhatsApp re-downloads the URL on **every** send. For bulk campaigns you can upload the asset to Meta once and reuse the returned media ID across every recipient, which skips that per-send fetch entirely: ```typescript theme={null} const response = await Templates.whatsapp.send('channel_123', 'promo_template', { phoneNumbers: ['+1234567890', '+0987654321'], values: { header: { image_id: '1234567890123456' }, body: { discount: '20%' }, }, }); ``` Use `image_id`, `video_id`, or `document_id` to match the template's header format. `document_filename` still applies. If you pass both a media ID and a URL, the media ID wins. Three constraints decide whether a media ID resolves at send time: * **Upload it via `POST /{phone-number-id}/media`.** The Resumable Upload API (`POST /{app-id}/uploads`) returns an `h:...` file handle instead, which is only valid for *creating* a template — it will never work as a send-time media ID. * **Media IDs are scoped to the business phone number they were uploaded on.** That must be the phone number behind the channel you're sending from, or Meta won't resolve the ID. * **Meta expires media IDs after 30 days.** Plan on re-uploading per campaign rather than once forever. ## Template Parameters Templates use named parameters in the format `{{parameter_name}}`. When sending, provide values for each parameter: ```typescript theme={null} // Template body: "Hello {{first_name}}, your order {{order_number}} is ready!" await Templates.whatsapp.send(channelId, templateId, { phoneNumbers: ['+447551166594'], values: { body: { first_name: 'John', order_number: 'ORD-12345' } } }); ``` All required parameters must be provided when sending a template. Missing parameters will cause the send to fail. ## WhatsApp Examples The following examples demonstrate using the Templates API with WhatsApp templates. ### Order Confirmation Tool ```typescript theme={null} import { LuaTool, Templates } from 'lua-cli'; import { z } from 'zod'; export default class SendOrderConfirmationTool implements LuaTool { name = "send_order_confirmation"; description = "Send order confirmation via WhatsApp template"; inputSchema = z.object({ channelId: z.string().describe("WhatsApp channel ID"), phoneNumber: z.string().describe("Customer phone number"), orderNumber: z.string().describe("Order number"), customerName: z.string().describe("Customer name"), deliveryDate: z.string().describe("Expected delivery date") }); async execute(input: z.infer) { const result = await Templates.whatsapp.send(input.channelId, 'order_confirmation', { phoneNumbers: [input.phoneNumber], values: { body: { customer_name: input.customerName, order_number: input.orderNumber, delivery_date: input.deliveryDate } } }); if (result.errors.length > 0) { return { success: false, error: result.errors[0].error }; } return { success: true, messageId: result.results[0].messageId, message: `Order confirmation sent to ${input.phoneNumber}` }; } } ``` ### Webhook: Send Welcome Template on New User ```typescript theme={null} import { LuaWebhook, LuaWebhookConfig, Templates } from 'lua-cli'; const config: LuaWebhookConfig = { name: 'new-user-welcome', description: 'Send welcome template when a new user signs up' }; const webhook: LuaWebhook = { config, execute: async (event) => { const { channelId, phoneNumber, firstName } = event.data; // Find welcome template const listResult = await Templates.whatsapp.list(channelId, { search: 'welcome' }); const welcomeTemplate = listResult.templates.find(t => t.name === 'welcome_message'); if (!welcomeTemplate) { return { error: 'Welcome template not found' }; } // Send welcome message const sendResult = await Templates.whatsapp.send(channelId, welcomeTemplate.id, { phoneNumbers: [phoneNumber], values: { body: { first_name: firstName } } }); return { sent: sendResult.results.length > 0, messageId: sendResult.results[0]?.messageId }; } }; export default webhook; ``` ### List Templates Tool ```typescript theme={null} import { LuaTool, Templates } from 'lua-cli'; import { z } from 'zod'; export default class ListTemplatesTools implements LuaTool { name = "list_whatsapp_templates"; description = "List available WhatsApp templates"; inputSchema = z.object({ channelId: z.string().describe("WhatsApp channel ID"), search: z.string().optional().describe("Search query to filter templates"), page: z.number().optional().describe("Page number") }); async execute(input: z.infer) { const result = await Templates.whatsapp.list(input.channelId, { search: input.search, page: input.page || 1, limit: 10 }); return { templates: result.templates.map(t => ({ id: t.id, name: t.name, status: t.status, category: t.category, language: t.language })), total: result.total, page: result.page, totalPages: result.totalPages }; } } ``` ## Use Cases (WhatsApp) ### Transactional Notifications ```typescript theme={null} // Order shipped await Templates.whatsapp.send(channelId, 'order_shipped', { phoneNumbers: [customerPhone], values: { body: { order_number: order.id, tracking_number: shipment.trackingNumber, carrier: shipment.carrier } } }); // Appointment reminder await Templates.whatsapp.send(channelId, 'appointment_reminder', { phoneNumbers: [patientPhone], values: { body: { patient_name: patient.name, appointment_date: appointment.date, doctor_name: appointment.doctor } } }); ``` ### Marketing Campaigns ```typescript theme={null} // Get all customers who opted in const customers = await getOptedInCustomers(); // Send promotion to all const result = await Templates.whatsapp.send(channelId, 'holiday_sale', { phoneNumbers: customers.map(c => c.phone), values: { header: { image_url: 'https://example.com/sale-banner.jpg' }, body: { discount_code: 'HOLIDAY25' } } }); console.log(`Sent: ${result.results.length}, Failed: ${result.errors.length}`); ``` ### Authentication / OTP ```typescript theme={null} // Send OTP const otp = generateOTP(); await Templates.whatsapp.send(channelId, 'otp_verification', { phoneNumbers: [userPhone], values: { body: { otp_code: otp } } }); ``` ## Tracking Delivery Status After sending template messages, you can track whether they were delivered, read, or failed using webhook event subscriptions. ### Setup 1. Create a webhook to handle delivery events 2. Subscribe it to the status events you care about ```bash theme={null} lua webhooks subscribe --webhook-name campaign-tracker --event message.sent lua webhooks subscribe --webhook-name campaign-tracker --event message.delivered lua webhooks subscribe --webhook-name campaign-tracker --event message.read lua webhooks subscribe --webhook-name campaign-tracker --event message.failed ``` ### Example: Campaign Analytics ```typescript theme={null} import { LuaWebhook, Data, Templates } from 'lua-cli'; const campaignTracker = new LuaWebhook({ name: 'campaign-tracker', description: 'Track template message delivery for campaigns', execute: async (event) => { const { body } = event; await Data.create('campaign-metrics', { messageId: body.messageWamid, recipient: body.recipientId, status: body.status, timestamp: body.timestamp, billable: body.pricing?.billable }, `${body.status} ${body.recipientId}`); return { tracked: true }; } }); export default campaignTracker; ``` After sending a batch of templates, your webhook receives individual status updates for each recipient as messages are sent, delivered, and read. Use this data to measure open rates, delivery rates, and catch failures. The `messageId` returned by `Templates.whatsapp.send()` corresponds to `messageWamid` in the status event payload, allowing you to correlate sends with delivery outcomes. ## Best Practices When sending to multiple recipients, always check for partial failures: ```typescript theme={null} const result = await Templates.whatsapp.send(channelId, templateId, { phoneNumbers: phoneNumbers }); // Log successes result.results.forEach(r => { console.log(`✓ Sent to ${r.phoneNumber}`); }); // Handle failures if (result.errors.length > 0) { result.errors.forEach(e => { console.error(`✗ Failed for ${e.phoneNumber}: ${e.error}`); }); } ``` Use E.164 format for phone numbers: ```typescript theme={null} // ✅ Good phoneNumbers: ['+447551166594', '+14155552671'] // ❌ May cause issues phoneNumbers: ['07551166594', '(415) 555-2671'] ``` Only send approved templates: ```typescript theme={null} const template = await Templates.whatsapp.get(channelId, templateId); if (template.status !== 'APPROVED') { throw new Error(`Template ${template.name} is not approved (status: ${template.status})`); } ``` Parameter names in `values` must match template parameters exactly: ```typescript theme={null} // If template has: "Hello {{first_name}}, order {{order_id}} is ready" // ✅ Correct values: { body: { first_name: 'John', order_id: '12345' } } // ❌ Wrong parameter names values: { body: { name: 'John', // Should be 'first_name' orderId: '12345' // Should be 'order_id' } } ``` ## TypeScript Types ```typescript theme={null} interface ListTemplatesOptions { page?: number; limit?: number; search?: string; } interface PaginatedTemplatesResponse { templates: Template[]; total: number; page: number; limit: number; totalPages: number; } // WhatsApp template structure interface WhatsAppTemplate { id: string; name: string; status: 'APPROVED' | 'PENDING' | 'REJECTED'; category: 'UTILITY' | 'MARKETING' | 'AUTHENTICATION'; language: string; components: TemplateComponent[]; correct_category?: string; message_send_ttl_seconds?: number; parameter_format?: 'NAMED' | 'POSITIONAL'; previous_category?: string; rejected_reason?: string; } interface SendTemplateData { phoneNumbers: string[]; // Recipients (phone numbers for WhatsApp) values?: { header?: Record; body?: Record; buttons?: SendTemplateButtonValue[]; }; } interface SendTemplateButtonValue { sub_type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE'; index: string; text?: string; coupon_code?: string; } interface SendTemplateResponse { results: Array<{ phoneNumber: string; success: boolean; messageId?: string; }>; errors: Array<{ phoneNumber: string; error: string; }>; totalProcessed: number; totalErrors: number; } ``` ## Next Steps Send messages to users Schedule template sends # User API Source: https://docs.heylua.ai/api/user Persistent per-user storage for state, preferences, and custom data ## Overview 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. User data is one record per user and agent. It is different from the [Data API](/api/data), which stores many entries in named, agent-owned collections and can index those entries for semantic search. `User.get()` is an exact identity lookup and does not accept a `filter`. APIs that do expose filters all use the same entity-independent [Lua Query language](/api/query); stored `user.*` properties are ordinary data and are never interpreted as query operators. ```typescript theme={null} import { User } from 'lua-cli'; // Get user instance const 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 sessions user.onboardingStep = 'verified'; user.lastProductViewed = 'SKU-123'; user.collectedData = { company: 'Acme', plan: 'enterprise' }; await user.save(); ``` ## Read-Only User Profile (`_luaProfile`) 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. The Lua profile and your custom User data are independent. `User.get()` is a read-only operation: it returns `_luaProfile` even when this user has not stored any custom data for the agent, and it does not create an empty data record. The first `update()` or `save()` creates that record when needed. Access read-only data like `user._luaProfile.userId` and `user._luaProfile.fullName`. Store any data on the user object — onboarding state, workflow progress, preferences, cart contents. Persists across all conversations and sessions. ### Deprecated `user.userId` 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. ```typescript theme={null} // ✅ New & Recommended const userId = user._luaProfile.userId; // ⚠️ Deprecated const oldUserId = user.userId; ``` ## User as a State Store 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. ### Onboarding State Machine Example ```typescript theme={null} 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 }; } } } ``` ### Common State Storage Patterns | Pattern | What to store on `user.*` | Example | | ------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | | **Onboarding flow** | `onboardingStep`, `completedSteps`, `collectedData` | Track which step the user is on, resume across sessions | | **Multi-step form** | `formData`, `currentSection`, `validationErrors` | Accumulate form data across multiple tool calls | | **Verification workflow** | `verificationStatus`, `documentsUploaded`, `verifiedAt` | Track identity/document verification progress | | **Feature adoption** | `featuresUsed`, `tutorialStep`, `firstActionAt` | Guide users through product discovery | | **Conversation context** | `lastIntent`, `pendingAction`, `conversationTopic` | Help the agent maintain context between sessions | ## Features Access properties with `user.name` instead of `user.data.name` Removes sensitive fields automatically `update()`, `save()`, `send()`, and `clear()` included Send text, images, and files to users File approval and notice cards with `User.Inbox.push()` ## Inbox (`User.Inbox`) `User.Inbox.push()` puts a card on the current user's desk — an approval to click, a notice to read, or an integration to reconnect — and returns a receipt straight away: ```typescript theme={null} import { User } from 'lua-cli'; const receipt = await User.Inbox.push({ title: 'Approve the Q3 renewal quote', body: 'Northstar Ltd renewal is ready to send at $48,000.', actions: ['approve'], key: 'northstar-q3-renewal', }); ``` Unlike `User.get(userId)`, it takes no recipient — the card always goes to the user of the current execution context, so it works in tools and dynamic jobs but not in context-less webhooks or pre-defined jobs. See the [Inbox API reference](/api/inbox) for card kinds, daily limits, revision keys, and the full receipt contract. ## get(identifier?) Retrieve user data as a UserDataInstance. Supports lookup by userId, email, or phone number. ```typescript theme={null} User.get(identifier?: string | UserLookupOptions): Promise ``` One of: * **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) Look up users by email or phone — especially useful in webhooks where you receive contact info from external systems but don't have the internal userId. Compiled voice does not currently resolve `{ email }` or `{ phone }` identifiers. In that runtime they may fall back to the current voice user, so use `User.get(userId)` for a portable explicit target before any write, including `update()`, `save()`, `patch()`, `unset()`, or `clear()`. ### Shortcut: `User.getChatHistory()` If you only need chat history for the **current** user (in a tool with conversational context), the top-level static `User.getChatHistory()` skips the `User.get()` step: ```typescript theme={null} import { User } from 'lua-cli'; const history = await User.getChatHistory(); ``` This is equivalent to `(await User.get()).getChatHistory()` — see the [instance method](#getchathistory) below for the full return shape and examples. ## When to Use userId Parameter 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: ```typescript theme={null} const user = await User.get({ email: 'customer@example.com' }); const user = await User.get({ phone: '+1234567890' }); ``` **Examples:** **Get the current user from conversation context:** ```typescript theme={null} import { User } from 'lua-cli'; // In your tool async execute(input: any) { const user = await User.get(); // Direct property access console.log(user.name); // "John Doe" console.log(user.email); // "john@example.com" console.log(user.phone); // "555-0123" return { userName: user.name }; } ``` **Get a specific user by ID (REQUIRED in webhooks and pre-defined jobs):** ```typescript theme={null} 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:** ```typescript theme={null} import { Jobs } from 'lua-cli'; // Creating a dynamic job from a tool await 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:** ```typescript theme={null} import { User } from 'lua-cli'; // In a webhook receiving customer contact info execute: async (event) => { const { email, phone } = event.body; // Look up by email const userByEmail = await User.get({ email: 'customer@example.com' }); // 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:** Email/phone lookup returns `null` when resolution fails. A string user ID returns a target-bound instance even when its User-data record does not exist yet, and a later write can create that record. Use explicit IDs only from a trusted, validated source, and always null-check email/phone results. ## UserDataInstance API ### Property Access (Direct) Access any user property directly: ```typescript theme={null} // Reading properties const name = user.name; const email = user.email; const preferences = user.preferences; const customField = user.customField; // Setting properties (local only - call update() to persist) user.name = "Jane Doe"; user.email = "jane@example.com"; user.preferences = { theme: "dark" }; // Checking existence if ('name' in user) { console.log('User has a name'); } ``` ### update() Update user data on the server and locally. ```typescript theme={null} user.update(data: Record): Promise ``` Object containing fields to update or add **Returns:** Promise resolving to updated sanitized user data **Examples:** ```typescript theme={null} // Update single field await user.update({ name: "John Doe" }); // Update multiple fields await user.update({ name: "John Doe", email: "john@example.com", phone: "555-1234" }); // Update nested objects await user.update({ preferences: { theme: "dark", notifications: true, language: "en" } }); // Access updated data immediately console.log(user.name); // "John Doe" console.log(user.preferences.theme); // "dark" ``` When the instance came from `User.get(identifier)`, `update()` writes to that same user. Existing code using `User.get()` without an identifier keeps the current-session behavior. ```typescript theme={null} const reassignedUser = await User.get('user_123'); if (!reassignedUser) throw new Error('User not found'); await reassignedUser.update({ rep_code: 'NEW_REP' }); ``` ### patch() Atomically set and remove top-level fields in one request. Setting a field to `null` stores `null`; only `unset` removes it. ```typescript theme={null} user.patch(mutation: { set?: Record; unset?: string[]; }): Promise ``` ```typescript theme={null} const user = await User.get('user_123'); if (!user) throw new Error('User not found'); await user.patch({ set: { roster_version: 12, status: null }, unset: ['rep_code'] }); ``` The mutation is atomic, so concurrent writers cannot observe a half-applied `set`/`unset` pair. A mutation must change at least one field, `set` and `unset` cannot contain the same field, and field names must be non-empty and cannot start with `$` or contain `.` or a null byte. ### unset() Remove one or more top-level fields. This is a convenience wrapper around `patch({ unset: fields })`. ```typescript theme={null} user.unset(...fields: string[]): Promise ``` ```typescript theme={null} const user = await User.get('user_123'); if (!user) throw new Error('User not found'); await user.unset('rep_code', 'legacy_assignment'); ``` In compiled voice, use a user ID (`User.get(userId)`) for an explicit cross-user target. Email and phone lookup require lua-auth resolution and are not currently supported in that runtime. ### save() Save the current state of user data to the server. This is a convenience method that persists all changes made to the user instance. ```typescript theme={null} user.save(): Promise ``` **Returns:** Promise resolving to `true` if successful **Examples:** ```typescript theme={null} const user = await User.get(); // Modify properties user.name = "John Doe"; user.email = "john@example.com"; user.phone = "555-1234"; // Save all changes at once await user.save(); // Much cleaner than multiple update calls! ``` **Tip:** The `save()` method provides a simpler workflow - modify properties then save, rather than passing data to `update()`. ### send() Send messages to the user conversation. Supports text, images, and file attachments. ```typescript theme={null} user.send(messages: Message[]): Promise ``` Array of messages to send (text, image, or file) **Message Types:** ```typescript theme={null} // Text message type TextMessage = { type: "text"; text: string; }; // Image message type ImageMessage = { type: "image"; image: string; // Base64 encoded image data mediaType: string; // e.g., "image/png", "image/jpeg" }; // File message type FileMessage = { type: "file"; data: string; // Base64 encoded file data mediaType: string; // e.g., "application/pdf" }; ``` **Examples:** ```typescript theme={null} const user = await User.get(); // Send a text message await user.send([ { type: "text", text: "Your order has been shipped!" } ]); // Send multiple messages await user.send([ { type: "text", text: "Here is your receipt:" }, { type: "image", image: base64ImageData, mediaType: "image/png" } ]); // Send a file await user.send([ { type: "text", text: "Your invoice is attached:" }, { type: "file", data: base64PdfData, mediaType: "application/pdf" } ]); ``` **`user.send()` vs [`Channels.send`](/api/channels):** `user.send()` delivers to the user on the channel they're **already active on** — simplest when you have a `User` and just want to reach them. Use [`Channels.send`](/api/channels) when you need to **choose a specific channel** (e.g. always WhatsApp), reach a **cold** phone number or email with no prior conversation, or send an approved WhatsApp template. Both record the message to the user's conversation thread. See [Proactive Messaging](/channels/proactive-messaging#three-ways-to-send). ### getChatHistory() Retrieve the conversation history for the current user with the active agent. Returns the last 40 user/assistant messages, transformed for display: hidden text is filtered out and embedded media (audio, video, files) is surfaced as structured `content` parts you can render directly. ```typescript theme={null} user.getChatHistory(): Promise ``` **Return shape:** ```typescript theme={null} interface ChatHistoryMessage { id: string; role: 'user' | 'assistant'; createdAt: string; // ISO-8601 content: ChatHistoryContent[]; } interface ChatHistoryContent { type: 'text' | 'image' | 'video' | 'audio' | 'file'; // For text: text?: string; // For media (depending on type): image?: string; // image URL or base64 video?: string; // video URL or base64 data?: string; // file/audio URL or base64 mediaType?: string; // e.g. "image/png", "audio/mpeg", "application/pdf" } ``` **Example:** ```typescript theme={null} const user = await User.get(); const history = await user.getChatHistory(); console.log(history.length); // Number of messages for (const msg of history) { for (const part of msg.content) { if (part.type === 'text') { console.log(`${msg.role}: ${part.text}`); } else if (part.type === 'image') { console.log(`${msg.role} sent image (${part.mediaType})`); } } } ``` ### clear() Clear all User data for the user represented by this instance. An instance returned by `User.get(target)` remains bound to that target. ```typescript theme={null} user.clear(): Promise ``` **Returns:** Promise resolving to `true` if successful The VM sandbox retains its legacy return shape `{ success: true }`; direct `lua-cli` and compiled voice return the boolean `true`. Both shapes are truthy. This difference is preserved for backward compatibility, so avoid strict equality checks when code must run in every runtime. **Example:** ```typescript theme={null} try { const user = await User.get('user_123'); if (!user) throw new Error('User not found'); const success = await user.clear(); if (success) { console.log('User data cleared successfully'); } } catch (error) { console.error('Failed to clear user data:', error); } ``` **Destructive operation!** This removes the complete custom-data record for the user represented by this instance. It does not delete their Lua profile or chat history. Use with caution. After `clear()`, a later `User.get()` can still read `_luaProfile`; the read does not recreate the deleted custom-data record. `lua chat clear` clears conversation history; it does not clear User data. Use `user.clear()` for the complete User-data record or `user.unset(...)` for selected fields. ## Data Sanitization UserDataInstance separates system identity from custom data: **System identity (read-only via `_luaProfile`):** * `userId`, `fullName`, `mobileNumbers`, `emailAddresses` * Extracted from response and made immutable — access via `user._luaProfile` **Custom data (read/write via direct properties):** * `name`, `email`, `phone` * Custom fields you set * Preferences, settings, and any other data ```typescript theme={null} const user = await User.get(); // System identity — read-only user._luaProfile.userId; // "12345" user._luaProfile.fullName; // "John Doe" // Custom data — read/write user.name; // "John Doe" user.email; // "john@example.com" user.preferences; // { theme: "dark" } ``` ## Complete Examples ### Example 1: User Preferences ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export class ManagePreferencesTool implements LuaTool { name = "manage_preferences"; description = "Manage user preferences"; inputSchema = z.object({ theme: z.enum(['light', 'dark']).optional(), notifications: z.boolean().optional(), language: z.string().optional() }); async execute(input: any) { const user = await User.get(); // Direct property access const currentPrefs = user.preferences || {}; // Merge with new preferences const newPrefs = { ...currentPrefs, ...input }; // Update on server await user.update({ preferences: newPrefs }); return { message: 'Preferences updated successfully', preferences: user.preferences // Direct access to updated data }; } } ``` ### Example 2: Shopping Cart Persistence ```typescript theme={null} export class CartTool implements LuaTool { name = "manage_cart"; description = "Manage shopping cart"; inputSchema = z.object({ action: z.enum(['add', 'remove', 'get']), productId: z.string().optional(), quantity: z.number().optional() }); async execute(input: any) { const user = await User.get(); // Get current cart (direct access) let cart = user.cart || []; if (input.action === 'add') { cart.push({ productId: input.productId, quantity: input.quantity }); await user.update({ cart }); } if (input.action === 'remove') { cart = cart.filter(item => item.productId !== input.productId); await user.update({ cart }); } return { cart: user.cart, // Direct access itemCount: cart.length }; } } ``` ### Example 3: User Profile Management ```typescript theme={null} export class ProfileTool implements LuaTool { name = "update_profile"; description = "Update user profile information"; inputSchema = z.object({ firstName: z.string().optional(), lastName: z.string().optional(), phone: z.string().optional(), bio: z.string().optional() }); async execute(input: any) { const user = await User.get(); // Update user data await user.update(input); // Return updated profile (direct access) return { success: true, profile: { firstName: user.firstName, lastName: user.lastName, phone: user.phone, bio: user.bio }, message: 'Profile updated successfully' }; } } ``` ### Example 4: Personalized Greeting ```typescript theme={null} export class GreetUserTool implements LuaTool { name = "greet_user"; description = "Greet user with personalized message"; inputSchema = z.object({}); async execute(input: any) { const user = await User.get(); // Direct property access const hour = new Date().getHours(); const timeGreeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; return { message: `${timeGreeting}, ${user.name}! How can I help you today?` }; } } ``` ### Example 5: Order Notification with Messaging ```typescript theme={null} export class SendOrderNotificationTool implements LuaTool { name = "send_order_notification"; description = "Send order status notification to user"; inputSchema = z.object({ orderId: z.string(), status: z.enum(['shipped', 'delivered']), trackingNumber: z.string().optional(), receiptUrl: z.string().optional() }); async execute(input: z.infer) { const user = await User.get(); // Build notification message const messages = [ { type: "text" as const, text: `Hi ${user.name}! Your order #${input.orderId} has been ${input.status}.` } ]; // Add tracking info if shipped if (input.status === 'shipped' && input.trackingNumber) { messages.push({ type: "text" as const, text: `Tracking number: ${input.trackingNumber}` }); } // Send messages to user await user.send(messages); // Update user's notification preferences user.lastNotificationSent = new Date().toISOString(); await user.save(); return { success: true, message: "Notification sent successfully" }; } } ``` ### Example 6: Send Receipt with Image ```typescript theme={null} export class SendReceiptTool implements LuaTool { name = "send_receipt"; description = "Send order receipt with QR code"; inputSchema = z.object({ orderId: z.string(), amount: z.number(), qrCodeBase64: z.string() }); async execute(input: z.infer) { const user = await User.get(); // Send receipt with QR code await user.send([ { type: "text", text: `Thank you for your order! Total: $${input.amount.toFixed(2)}` }, { type: "text", text: "Here's your receipt QR code for easy access:" }, { type: "image", image: input.qrCodeBase64, mediaType: "image/png" } ]); return { success: true, message: "Receipt sent to user" }; } } ``` ## Best Practices ```typescript theme={null} // ✅ Recommended const name = user.name; const email = user.email; // 🟡 Still works but verbose const name = user.data.name; const email = user.data.email; ``` Combine multiple updates into one call: ```typescript theme={null} // ✅ Good - Single update call await user.update({ name: "John Doe", email: "john@example.com", phone: "555-1234" }); // ❌ Bad - Multiple update calls await user.update({ name: "John Doe" }); await user.update({ email: "john@example.com" }); await user.update({ phone: "555-1234" }); ``` Not all fields may be present: ```typescript theme={null} const phone = user.phone || 'Not provided'; const name = user.name || 'Guest'; const preferences = user.preferences || {}; ``` ```typescript theme={null} // Personalized responses return { message: `Welcome back, ${user.name}!`, savedItems: user.cart?.length || 0, lastVisit: user.lastSeen }; ``` The new `save()` method is perfect for multiple changes: ```typescript theme={null} // ✅ Recommended - Modify then save user.name = "John Doe"; user.email = "john@example.com"; user.preferences = { theme: "dark" }; await user.save(); // 🟡 Still works - Update method await user.update({ name: "John Doe", email: "john@example.com", preferences: { theme: "dark" } }); ``` Send messages to users for order updates, alerts, and more: ```typescript theme={null} // Send order shipped notification await user.send([ { type: "text", text: "Your order has been shipped!" }, { type: "text", text: "Tracking: TRACK123456" } ]); // Send with image await user.send([ { type: "text", text: "Your boarding pass:" }, { type: "image", image: qrCodeBase64, mediaType: "image/png" } ]); ``` Ensure correct message format: ```typescript theme={null} // ✅ Correct await user.send([ { type: "text", text: "Hello!" } ]); // ✅ Multiple messages await user.send([ { type: "text", text: "Message 1" }, { type: "text", text: "Message 2" } ]); // ❌ Wrong - Must be array await user.send({ type: "text", text: "Hello!" }); ``` ## TypeScript Support ```typescript theme={null} // User lookup options for email/phone lookup interface UserLookupOptions { /** Email address to look up */ email?: string; /** Phone number to look up (with or without + prefix) */ phone?: string; } interface UserDataInstance { // Properties (dynamic based on stored data) name?: string; email?: string; phone?: string; [key: string]: any; // Methods update(data: Record): Promise; patch(mutation: { set?: Record; unset?: string[] }): Promise; unset(...fields: string[]): Promise; save(): Promise; send(messages: Message[]): Promise; clear(): Promise; toJSON(): Record; } // Message types type TextMessage = { type: "text"; text: string; }; type ImageMessage = { type: "image"; image: string; mediaType: string; }; type FileMessage = { type: "file"; data: string; mediaType: string; }; type Message = TextMessage | ImageMessage | FileMessage; ``` ### Usage with Types ```typescript theme={null} // Define your user data shape interface MyUserData { name: string; email: string; preferences: { theme: 'light' | 'dark'; notifications: boolean; }; } // Access with type safety const user = await User.get(); const name: string = user.name; const theme = user.preferences?.theme || 'light'; ``` If user properties aren't what you expect, log the user object to see what's actually stored: ```typescript theme={null} const user = await User.get(); console.log('User data:', JSON.stringify(user.data, null, 2)); console.log('User profile fields:', JSON.stringify({ name: user.name, email: user.email }, null, 2)); ``` Then run `lua logs --type skill --limit 5` after a test message. See the [Debugging Skills guide](/cli/debugging) for the full workflow. ## Next Steps Store agent-owned collection entries See working examples Inspect runtime return values File approval and notice cards # LuaVoice Source: https://docs.heylua.ai/api/voice Define voice agents in code with LuaVoice — STT, TTS, LLM, lifecycle hooks, and voice tools ## Overview `LuaVoice` is the class-based primitive you define in code to declare a voice-enabled agent — its speech-to-text engine, text-to-speech engine, LLM, turn detection, and any voice-specific tools. ```typescript theme={null} import { LuaVoice } from 'lua-cli'; export default new LuaVoice({ name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', stt: 'deepgram/nova-3', tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', greeting: 'Hi, this is your support line. How can I help?', }); ``` For testing voice agents live or running automated voice tests, see the [Voice Command](/cli/voice-command). For the direct plugin route (when string descriptors aren't enough), see [Plugin and Realtime Engines](#plugin-and-realtime-engines) below. **Persona is configured on the parent `LuaAgent`, not on `LuaVoice`.** Use the channel-aware persona shape `{ base, voice, text }` on the agent to give a voice its own prompt — see [Channel-Aware Personas](/cli/persona-command#channel-aware-personas). *** ## String Descriptors (recommended) `llm`, `stt`, and `tts` all accept a provider-prefixed string descriptor. This is the canonical form — it routes through Lua's inference layer so you don't manage provider credentials yourself. ```typescript theme={null} new LuaVoice({ name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', // LLM stt: 'deepgram/nova-3', // STT tts: 'elevenlabs/eleven_turbo_v2_5:', // TTS — colon-separated voiceId }); ``` The model and voice catalogs below are a **living list** — your descriptor is forwarded straight to Lua's inference layer, so newer provider models may work before they're listed here and retired ones may drop off. Treat these tables as a starting point, not an exhaustive allowlist. ### LLM options Provider-prefixed model id. Grouped by tier — pick a tier based on the latency/cost/quality trade-off you need. **Fast tier** — lowest latency, lowest cost: | Descriptor | Notes | | --------------------------------- | --------------------- | | `openai/gpt-5-mini` | Fast & cheap OpenAI. | | `openai/gpt-5-nano` | Cheapest OpenAI tier. | | `openai/gpt-4.1-mini` | Stable, fast. | | `google/gemini-2.5-flash-lite` | Fastest Gemini. | | `google/gemini-2.5-flash` | Fast multimodal. | | `xai/grok-4-1-fast-non-reasoning` | Fast xAI tier. | **Balanced tier** — good default for most voice agents: | Descriptor | Notes | | ----------------------------- | ----------------------------------------- | | `openai/gpt-5` | Balanced quality and speed. | | `openai/gpt-5.1-chat-latest` | Balanced, chat-tuned. **Common default.** | | `openai/gpt-4.1` | Stable, balanced. | | `google/gemini-3-flash` | Newest Flash multimodal. | | `xai/grok-4-1-fast-reasoning` | Reasoning at fast tier. | | `deepseek-ai/deepseek-v3.2` | Cost-efficient reasoning. | | `moonshotai/kimi-k2-instruct` | Long-context instruct. | **Quality tier** — best capability, higher latency/cost: | Descriptor | Notes | | ------------------------------ | ----------------------- | | `openai/gpt-5.4` | Top-tier OpenAI. | | `openai/gpt-5.3-chat-latest` | Top-tier chat-tuned. | | `google/gemini-3-pro` | Long context, top tier. | | `google/gemini-2.5-pro` | Stable Pro tier. | | `xai/grok-4.20-0309-reasoning` | Top-tier xAI reasoning. | **Anthropic / Claude is intentionally absent** — Lua's inference layer does not carry Anthropic models for voice as of this writing. Use OpenAI, Google, xAI, DeepSeek, or Kimi for voice LLMs. ### STT options #### Deepgram (recommended) Deepgram is the recommended STT provider, and `deepgram/nova-3` is the standard choice. `stt` is **required** for cascaded LLMs — omit it only when the `llm` is a realtime speech-to-speech model (which handles audio directly). ```typescript theme={null} new LuaVoice({ // ... stt: 'deepgram/nova-3', sttLanguage: 'en', // BCP-47 code, or 'multi' for multilingual }); ``` | Descriptor | Notes | | --------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `deepgram/nova-3` | Latest Nova series. Best accuracy + low latency. **Recommended default.** | | `deepgram/nova-2` | Previous generation. Still solid. | | `deepgram/nova-2-phonecall` | Tuned for narrowband (8 kHz) phone audio. Use when call quality is poor or when you want extra robustness on PSTN. | Combine with `sttLanguage` to pin the spoken language: * BCP-47 code (`'en'`, `'es'`, `'pt-BR'`, etc.) — pins recognition to that language. * `'multi'` — multilingual transcription. Applies to both the Inference route and the direct Deepgram plugin. Want non-default Deepgram options (smart formatting, filler-word filtering, custom keywords)? Use the plugin class form: `stt: new deepgram.STT({ model: 'nova-3', smartFormat: true })`. See [Plugin and Realtime Engines](#plugin-and-realtime-engines) for the full plugin route. #### ElevenLabs Scribe ElevenLabs has an STT model called Scribe, available via the Inference route: ```typescript theme={null} stt: 'elevenlabs/scribe_v2_realtime' ``` Useful when you want STT and TTS from the same provider, or when Scribe's behavior on a specific language outperforms Deepgram in your testing. ### TTS options **Recommended:** Deepgram and Fish Audio give the best balance of quality and latency. All providers below are fully supported. #### ElevenLabs ElevenLabs voices use the descriptor format `elevenlabs/:`. ```typescript theme={null} new LuaVoice({ // ... tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', }); ``` **Models:** | Model | Latency | Languages | Best for | | ------------------------ | ------- | ------------ | ------------------------------------------------------------ | | `eleven_v3` | \~250ms | 70+ | Most expressive. Use when quality matters more than latency. | | `eleven_turbo_v2_5` | Low | Multilingual | **Common default** — balanced latency + quality. | | `eleven_flash_v2_5` | \~75ms | Multilingual | Ultra-low latency. Use for fast, interactive turns. | | `eleven_multilingual_v2` | \~200ms | 29 | Lifelike emotion across many languages. | | `eleven_flash_v2` | \~75ms | English only | Ultra-low latency, English-only. | **Curated voice IDs:** Lua maintains a curated list with metadata (gender, accent, style) the raw ElevenLabs API doesn't expose: | Voice ID | Name | Accent | Style | | ---------------------- | --------- | ---------- | --------------------------------- | | `pwMBn0SsmN1220Aorv15` | Matt | American | Male, Hyper-Conversational | | `ZTho75k1M56OV0k9XtSC` | Spence | American | Male, Soft-Spoken | | `kdmDKE6EkgrWrrykO9Qt` | Alexandra | American | Female, Conversational | | `h2sm0NbeIZXHBzJOMYcQ` | Natasha | American | Female, Calm Narrative | | `lUTamkMw7gOzZbFIwmq4` | James | British | Male, Professional | | `4BWwbsA70lmV7RMG0Acs` | Blondie | British | Female, Relaxed Casual | | `lcMyyd2HUfFzxdCaC4Ta` | Lucy | British | Female, Fresh Casual | | `4CrZuIW9am7gYAxgo2Af` | Shelley | British | Female, Clear Confident | | `56bWURjYFHyYyVf490Dp` | Emma | Australian | Female, Warm Conversational | | `aCChyB4P5WEomwRsOKRh` | Salma | Arabic | Female, Conversational Expressive | | `2zRM7PkgwBPiau2jvVXc` | Monika | Indian | Female, Deep and Natural | | `ecp3DWciuUyW7BYM7II1` | Anika | Indian | Female, Sweet and Lively | | `pzxut4zZz4GImZNlqQ3H` | Raju | Indian | Male, Natural Conversationalist | You can also use any ElevenLabs voice ID from your own ElevenLabs account — these are just the curated defaults. **Alternative: object form** If you'd rather not concatenate model and voice with a colon, the object form works too: ```typescript theme={null} tts: { model: 'elevenlabs/eleven_turbo_v2_5', voice: 'pwMBn0SsmN1220Aorv15' } ``` #### Deepgram Aura (recommended) Deepgram offers TTS via the Aura family. The voice id is encoded inside the model id as `aura-2--`: ```typescript theme={null} tts: 'deepgram/aura-2-thalia-en' ``` **Common Aura 2 voices (English):** | ID | Name | Gender | Style | | ------------------- | ------- | ----------------- | ------------------ | | `aura-2-thalia-en` | Thalia | Female (American) | Conversational | | `aura-2-asteria-en` | Asteria | Female (American) | Friendly | | `aura-2-luna-en` | Luna | Female (American) | Warm | | `aura-2-stella-en` | Stella | Female (American) | Professional | | `aura-2-athena-en` | Athena | Female (British) | Authoritative | | `aura-2-hera-en` | Hera | Female (American) | Calm Narrative | | `aura-2-orion-en` | Orion | Male (American) | Confident | | `aura-2-arcas-en` | Arcas | Male (American) | Conversational | | `aura-2-perseus-en` | Perseus | Male (American) | Engaging | | `aura-2-angus-en` | Angus | Male (Irish) | Storyteller | | `aura-2-helios-en` | Helios | Male (British) | Professional | | `aura-2-zeus-en` | Zeus | Male (American) | Deep Authoritative | Spanish voices are also available: `aura-2-celeste-es`, `aura-2-estrella-es`. #### Other TTS providers (via Inference) Lua's inference layer also exposes Fish Audio, Gradium, Cartesia, Inworld, Rime, and xAI TTS. The descriptors follow the same `provider/model` shape: | Descriptor | Provider | Notes | | ----------------------------- | ---------- | --------------------------------------------------------------------- | | `fishaudio/s2.1-pro` | Fish Audio | **Recommended** — expressive, multilingual, ElevenLabs-grade quality. | | `gradium/default` | Gradium | Natural and warm across English, French, German, Spanish, Portuguese. | | `cartesia/sonic-3` | Cartesia | Newest, expressive. | | `cartesia/sonic-turbo` | Cartesia | Ultra-low latency. | | `inworld/inworld-tts-1.5-max` | Inworld | High-quality multilingual. | | `rime/coda` | Rime | Multilingual, expressive. | | `xai/tts-1` | xAI | 21 languages. | *** ## Plugin and Realtime Engines For most voice agents the [string-descriptor form](#string-descriptors-recommended) above is all you need. Reach for the plugin/class forms here in two cases: (1) you need provider-specific options the descriptor route doesn't expose, or (2) you're using a realtime (speech-to-speech) model in the `llm` slot. `lua-cli/voice` re-exports the LiveKit plugin namespaces that `LuaVoice` accepts as class instances — importing through it means you don't add the underlying plugin packages as direct dependencies: ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { deepgram, elevenlabs, openai, google, xai, inference } from 'lua-cli/voice'; ``` ### What's allowed where The compiler enforces two separate allowlists: | Form | Allowed in `llm` / `stt` / `tts` | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'/'` string descriptor | **Any** provider supported by Lua's inference layer. The descriptor route handles credentials. | | `new deepgram.({...})` | Plugin route. Only `deepgram` and `elevenlabs` are allowlisted. | | `new elevenlabs.({...})` | Plugin route. Only `deepgram` and `elevenlabs` are allowlisted. | | `new inference.({ model, ... })` | Typed shortcut for the descriptor route — same semantics as a string descriptor, just with autocomplete on the options. | | `new .realtime.RealtimeModel({...})` | Realtime route. `openai`, `google` (via `google.beta.realtime.*`), `xai` are allowlisted **for realtime only** (goes in the `llm` slot, replaces STT+TTS). | **`new openai.LLM(...)`, `new google.LLM(...)`, `new xai.LLM(...)` and similar class forms fail compile-time validation.** These providers are not on the plugin allowlist. Use string descriptors (`'openai/gpt-5'`) or — for speech-to-speech — the realtime form (`new openai.realtime.RealtimeModel({...})`). ### Plugin route: Deepgram + ElevenLabs The two allowlisted plugin providers. Use these class forms when you need provider-specific options not exposed by the string-descriptor route. #### Deepgram STT (plugin form) ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { deepgram, elevenlabs } from 'lua-cli/voice'; export default new LuaVoice({ name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', stt: new deepgram.STT({ model: 'nova-3', smartFormat: true, fillerWords: false, }), tts: new elevenlabs.TTS({ voiceId: 'pwMBn0SsmN1220Aorv15', model: 'eleven_flash_v2_5', }), }); ``` Deepgram exposes two STT classes: * **`new deepgram.STT({...})`** — Deepgram's v1 WebSocket endpoint. Use this for `nova-3`, `nova-2`, etc. * **`new deepgram.STTv2({...})`** — Deepgram's v2 endpoint. Required for **Flux** models that use semantic endpointing (`eotThreshold`, `eagerEotThreshold`, `eotTimeoutMs`). The compiler routes each to the correct underlying plugin based on which class you used. #### ElevenLabs TTS (plugin form) ```typescript theme={null} tts: new elevenlabs.TTS({ voiceId: 'pwMBn0SsmN1220Aorv15', model: 'eleven_v3', stability: 0.5, similarityBoost: 0.75, }); ``` The plugin route lets you pass advanced ElevenLabs options (stability, similarity boost, style, speaker boost, etc.) that the descriptor route doesn't surface. ### Inference route (typed shortcut) `inference.LLM`, `inference.STT`, `inference.TTS` are typed wrappers for the string-descriptor route. The compiler normalizes both forms to the same wire shape; the class form just gives you better TypeScript autocomplete on the options. ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { inference } from 'lua-cli/voice'; export default new LuaVoice({ name: 'support-line', llm: new inference.LLM({ model: 'openai/gpt-5.1-chat-latest' }), stt: new inference.STT({ model: 'deepgram/nova-3' }), tts: new inference.TTS({ model: 'elevenlabs/eleven_turbo_v2_5', voice: 'pwMBn0SsmN1220Aorv15', }), }); ``` The `model` option is required — it's the same provider-prefixed string you'd pass directly. For TTS, pass `voice` separately. This is the **only** way to use class syntax for providers that aren't on the plugin allowlist (OpenAI, Google, xAI, Cartesia, etc.). ### Realtime route (speech-to-speech) The realtime route puts a speech-to-speech model in the `llm` slot, replacing the cascaded STT → LLM → TTS pipeline. The class-construction path differs by provider: * **OpenAI**: `new openai.realtime.RealtimeModel({...})` * **Google (Gemini)**: `new google.beta.realtime.RealtimeModel({...})` — note the `.beta.` prefix (matches Google's Node SDK shape) ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { openai } from 'lua-cli/voice'; export default new LuaVoice({ name: 'realtime-line', llm: new openai.realtime.RealtimeModel({ model: 'gpt-realtime-1.5', voice: 'alloy', }), // stt and tts are NOT specified — realtime handles audio directly. }); ``` ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { google } from 'lua-cli/voice'; export default new LuaVoice({ name: 'realtime-gemini', llm: new google.beta.realtime.RealtimeModel({ model: 'gemini-3.1-flash-live-preview', }), }); ``` #### Available realtime models | Class form | Model id | Notes | | ------------------------------------------------------------------------------------ | ------------------------------- | ------------------------------------ | | `new openai.realtime.RealtimeModel({ model: 'gpt-realtime-1.5' })` | `gpt-realtime-1.5` | OpenAI flagship realtime. GA. | | `new openai.realtime.RealtimeModel({ model: 'gpt-realtime-mini' })` | `gpt-realtime-mini` | Cost-efficient OpenAI realtime. GA. | | `new google.beta.realtime.RealtimeModel({ model: 'gemini-3.1-flash-live-preview' })` | `gemini-3.1-flash-live-preview` | Newest Gemini realtime. Preview. | | `new google.beta.realtime.RealtimeModel({ model: 'gemini-2.5-flash-live-preview' })` | `gemini-2.5-flash-live-preview` | Cheaper Gemini alternative. Preview. | `xai` is reserved in the realtime allowlist but no xAI realtime models are currently published. #### Half-cascade mode You can keep a separate `tts` with a realtime LLM — the worker injects `modalities: ['text']` so the realtime model emits text and `tts` handles synthesis. Useful when you want realtime's low-latency reasoning but ElevenLabs' voice quality: ```typescript theme={null} new LuaVoice({ name: 'hybrid-line', llm: new openai.realtime.RealtimeModel({ model: 'gpt-realtime-mini' }), // stt omitted — realtime handles input audio. tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', }); ``` You **cannot** combine a realtime `llm` with a custom `stt` — the compiler rejects it. Realtime models handle audio input directly. ### Credentials Plugin class instances rely on credentials provisioned by the Lua platform — you do **not** need to set `DEEPGRAM_API_KEY`, `ELEVENLABS_API_KEY`, etc. in your project's `.env`. Lua manages the provider credentials for you; your code just references the class form and the platform constructs the actual engine at runtime. ### When to use which form | Goal | Recommended form | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Quick start, sensible defaults | **String descriptor** — `stt: 'deepgram/nova-3'` | | TypeScript autocomplete on options | **`inference.X`** — `stt: new inference.STT({ model: 'deepgram/nova-3' })` | | Deepgram or ElevenLabs with provider-specific options | **Plugin class** — `stt: new deepgram.STT({ model: 'nova-3', smartFormat: true })` | | Speech-to-speech (OpenAI/Google/xAI realtime) | **Realtime class** — `llm: new openai.realtime.RealtimeModel({ model: 'gpt-realtime-1.5' })` | *** ## Configuration Reference ```typescript theme={null} new LuaVoice({ // Required name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', stt: 'deepgram/nova-3', tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', // Recommended description: 'Inbound phone voice for the support assistant', greeting: "Hi, this is your support line. How can I help?", sttLanguage: 'en', turnDetection: 'vad', krispEnabled: true, // Optional tuning maxToolSteps: 6, userAwayTimeout: 20, preemptiveGeneration: true, interruption: { mode: 'adaptive', falseInterruptionTimeout: 2.0 }, // Optional polish pronunciations: { 'HVAC': 'H V A C', 'CFM': 'C F M' }, persistTranscript: true, onToolFailureSay: 'Sorry, let me try that another way.', backgroundAudio: { ambient: 'office-ambience', thinking: 'keyboard-typing' }, // Tools + lifecycle hooks tools: [/* ... */], onEnter: async (ctx) => {/* ... */}, onUserTurnCompleted: async (turnCtx, message) => {/* ... */}, onExit: async (ctx) => {/* ... */}, }); ``` ### Required fields Unique name for this voice. Used to address the voice in `lua voice --voice ` and as the server-side identifier. Allowed characters: `a-zA-Z0-9_-`, 1–64 chars. The LLM that drives the conversation. String descriptor (e.g. `'openai/gpt-5.1-chat-latest'`) is the canonical form. See [LLM options](#llm-options) above for the catalog. Speech-to-text engine. String descriptor (e.g. `'deepgram/nova-3'`) is canonical. Required for cascaded LLMs; omit only when using a realtime speech-to-speech model in the `llm` slot. Text-to-speech engine. String descriptor with colon-separated voice id (e.g. `'elevenlabs/eleven_turbo_v2_5:'`), or object form `{ model, voice }`. Required for cascaded LLMs. ### Optional fields Human-readable description. Surfaced in the compiled manifest and admin listings. Opening line spoken at session start. Empty string means no greeting. Generated through the LLM at session connect, so it can be dynamic if `onEnter` sets up context first. BCP-47 language code (e.g. `'en'`, `'es'`, `'pt-BR'`) or `'multi'` for multilingual transcription. Applies to both Inference STT and the Deepgram plugin. How the agent decides when the user has finished speaking. `'vad'` is the safest choice for most setups. `'multilingual'` and `'english'` use LiveKit's turn-detector model; `'manual'` defers to your own logic. Voice activity detection engine. `'silero'` is the only currently-supported value. Silero VAD tuning. Useful when the default endpointing clips quiet callers or fires too eagerly mid-thought. * `minSpeechDuration` (ms, 0–5000) — speech required before a turn starts. Default: 50. * `minSilenceDuration` (ms, 0–5000) — silence required to end a turn. Default: 550. * `prefixPaddingDuration` (ms, 0–2000) — audio captured before detected speech start, forwarded into STT. Default: 500. * `activationThreshold` (0–1) — lower = more sensitive to speech onset. Krisp BVC background noise cancellation. Recommended for inbound phone calls — it removes background chatter, traffic, and other ambient noise. Billed separately, so opt-in. Maximum sequential tool calls per turn (1–20). Higher values let the agent chain more tools before responding. Seconds of silence before the agent considers the user "away" and ends the session. Useful for cleanly handling abandoned calls. Generate the assistant's response speculatively as the user is still speaking. Reduces perceived latency for predictable turns but can be wasted on highly interruptive callers. How the agent handles being interrupted mid-response. * `enabled` — whether interruption is allowed. * `mode` — `'adaptive'` (recommended) or `'vad'`. * `falseInterruptionTimeout` (seconds) — how long to wait before treating a brief noise as a false interruption. * `resumeFalseInterruption` (boolean) — resume the cut-off response after a false interruption. * `minDelay` / `maxDelay` (seconds) — bounds on the interruption response window. Word-boundary text replacements applied before TTS synthesis. Keys are matched case-insensitively as whole words. Use for acronyms and proper nouns the TTS mispronounces. ```typescript theme={null} pronunciations: { 'HVAC': 'H V A C', 'kubectl': 'kube control' } ``` Cascaded path only. Setting `pronunciations` on a **full-realtime** voice (realtime `llm` with no `tts`) is rejected at compile time — pair with a half-cascade `tts`, or drop the field. Background audio layered onto the agent's output. Pass a built-in clip name, a `{ source, volume, probability }` config, or an array (probabilistic mix). Built-in clips: `'office-ambience'`, `'keyboard-typing'`, `'keyboard-typing-2'`. ```typescript theme={null} backgroundAudio: { ambient: 'office-ambience', thinking: 'keyboard-typing', } ``` Output speech volume, 0–100. Applied as a per-frame multiplier. Omit to pass the TTS provider's native level through unchanged. When `true`, the worker writes `session.history` to `Data.set('call:')` after the call ends. Read it back from a job or webhook with `Data.get('call:')` for post-call analytics, follow-ups, or QA. Short line spoken to the caller when a tool call fails (throws, times out, or returns an unsupported result) — fills the 2–3s gap before the LLM's own recovery response. Spoken once per failed call, then the error is surfaced to the LLM. Keep it short and on-brand (e.g. `'Sorry, let me try that another way.'`); omit for no spoken fallback. Voice-specific tools in addition to skills attached to the owning agent. See [Defining Voice Tools](#defining-voice-tools). *** ## Lifecycle Hooks Three hooks let you wire up per-session state, RAG injection, and post-call work. Fires after the session connects to the room and **before** the greeting. Use it to hydrate `session.userdata` from `User`, `Data`, etc., or to set up any per-call state. ```typescript theme={null} onEnter: async (ctx) => { if (ctx.caller?.phoneNumber) { const user = await User.get({ phone: ctx.caller.phoneNumber }); ctx.session.userdata = { user, returning: !!user }; } }, ``` Fires after the user finishes a turn, **before** the LLM is invoked. This is the canonical RAG-injection point — `turnCtx.addMessage(...)` adds context messages the LLM sees on this turn. ```typescript theme={null} onUserTurnCompleted: async (turnCtx, message) => { const docs = await Data.search('kb', message.content, 3); for (const doc of docs) { turnCtx.addMessage({ role: 'system', content: doc.text }); } }, ``` Fires when the session is closing. Use for transcript persistence, outcome reporting, CRM updates, etc. *** ## Defining Voice Tools Voice tools run during a voice conversation. `LuaVoiceTool` is a concrete class — **instantiate** it with a config object: ```typescript theme={null} import { LuaVoiceTool } from 'lua-cli'; import { z } from 'zod'; export const getOrderStatusTool = new LuaVoiceTool({ name: 'getOrderStatus', description: 'Look up the status of an order by ID', inputSchema: z.object({ orderId: z.string() }), execute: async (input, ctx) => { const order = await Data.get('orders', input.orderId); return { status: order.status, eta: order.eta }; }, }); ``` ### Config fields Tool name. Used by the LLM to identify and call the tool. What the tool does. Action-oriented description the LLM reads when deciding to invoke. Zod schema for the tool's input. Validated before `execute` is called. Tool body. Receives the validated input and an optional voice-specific context. Optional gate. When provided, the tool is only exposed to the LLM if `condition()` returns `true`. Use for feature flags or runtime availability checks. Voice-specific tool flags (e.g. controlling barge-in behavior). ### ctx — `LuaVoiceToolCtx` Identifier for this specific tool invocation. Speak `text` to the caller via the active LiveKit session. Useful for status updates during long-running tool work ("Looking that up — one moment."). Transfer the live caller to a human at `msisdn`. Two mechanisms: * **`mode: 'refer'`** (default) — SIP REFER on the inbound leg. Cheap (one billed leg) but depends on the inbound carrier accepting REFER end-to-end. * **`mode: 'bridge'`** — dial the human as a second SIP participant into the same room. Two billed legs but works regardless of carrier REFER support. Use for high-stakes transfers. `announce` is spoken before the transfer fires. ```typescript theme={null} await ctx.voice?.transferToHuman('+32477123456', { mode: 'bridge', announce: 'Transferring you to our sales team — one moment.', }); ``` You can also share regular `LuaTool` instances between chat skills and voice tools — just pass them in the same `tools` array. The `tools` field accepts both `LuaTool` and `LuaVoiceTool` instances. *** ## Function-style: `defineVoice` Equivalent to `new LuaVoice(config)` if you prefer a function call: ```typescript theme={null} import { defineVoice } from 'lua-cli'; export default defineVoice({ name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', stt: 'deepgram/nova-3', tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', }); ``` *** ## Wiring Up to an Agent ```typescript theme={null} import { LuaAgent } from 'lua-cli'; import supportLine from './voices/support-line.voice'; import supportSkill from './skills/support.skill'; export const agent = new LuaAgent({ name: 'support-agent', persona: { base: 'You are a helpful support agent for Acme Corp.', voice: `Speak conversationally in two sentences or fewer. No markdown. Never output digits — spell numbers and prices in full English words ("one hundred twenty-nine dollars", "nine o'clock", "fifty miles").`, text: 'Use markdown headers and bullet lists where helpful.', }, voices: [supportLine], skills: [supportSkill], }); ``` The agent's `persona.voice` branch is what gives `supportLine` its voice-specific prompt. **Voice persona tips:** * Keep replies short (1–2 sentences). Voice users can't skim. * No markdown — TTS reads it literally. * Spell out numbers and prices ("nine o'clock", "twenty dollars") — TTS reads digits robotically otherwise. ### Connect a phone number Attaching the voice in code makes it *available*; to make the agent **answer phone calls**, bind a number to it. Push your voice first, then run the channels flow and choose **"☎️ Manage phone numbers"**: ```bash theme={null} lua push # publish the agent + its LuaVoice lua channels # → choose "☎️ Manage phone numbers" ``` From there you can **search** available numbers, **purchase** one, and **bind** it to this agent. During bind you pick **which LuaVoice answers inbound calls** on that number: ```text theme={null} 📞 Your agent is now reachable at +1 (415) 555-0142 via voice "support-line" ``` Binding requires a code-defined LuaVoice that has been pushed. Without one, inbound calls fall through to platform-default STT/LLM/TTS — no greeting, lifecycle hooks, or voice-only tools. Author the voice, `lua push`, then bind. When purchasing, answering **"Allow customers to text this number too?"** with **yes** provisions a voice **+ SMS** number; **no** provisions a voice-only number with lower-latency inbound. See [Channels Command](/cli/channels-command) for the full phone-number flow (list, unbind, release). *** ## Related * [Voice Command](/cli/voice-command) — live testing and voice test suites * [Plugin and Realtime Engines](#plugin-and-realtime-engines) — Deepgram/ElevenLabs class forms, realtime speech-to-speech * [Persona Command](/cli/persona-command#channel-aware-personas) — voice-specific personas on the parent agent * [LuaAgent API](/api/luaagent) # Changelog Source: https://docs.heylua.ai/changelog Release notes and version history for lua-cli ## v3.30.0 **Released:** September 2, 2026 ### ✨ New Features Every `Channels.send`, template, reaction and email send now returns a `deliveryId` and a `status` (`queued`, `accepted`, `sent`, `delivered`, `read`, `failed`). Read the current state later, and the reason category for a failure: ```typescript theme={null} const sent = await Channels.send({ channel: 'whatsapp', to: { userId }, text: 'Your order shipped.' }); const delivery = await Channels.getStatus(sent.deliveryId); // delivery.status === 'failed' && delivery.error.category === 'window_closed' ``` Webhooks subscribed to `message.sent`, `message.delivered`, `message.read` and `message.failed` now receive them for SMS, email, Messenger, Instagram and the other channels, not only WhatsApp, with the full delivery in the payload. Sends carry an idempotency key. Retrying a send with the same key returns the original outcome instead of sending twice. Template env params can declare `type: model` so installers pick a model instead of typing a code. ### 🔧 Improvements Send results keep the `delivered` flag; prefer `status`, which keeps updating as receipts arrive. *** ## v3.29.1 **Released:** August 31, 2026 ### 🐛 Bug Fixes Email OTP setup now saves the renewable session instead of failing after verification. Renewable sessions now load all current organizations and agents, including accounts with more than 100 organizations. *** ## v3.29.0 **Released:** August 31, 2026 ### ✨ New Features Email sign-in now creates a renewable user session that follows your current organizations and agents. Login no longer asks you to choose an organization, agents, or role. Run `lua init` to select an agent for a project. The selection stays in that project's `lua.skill.yaml`. Signed-in users can create or duplicate agents when their current Lua permissions allow it. ### 🔧 Improvements Email login no longer creates an agent-scoped API key. Existing API keys remain valid. Create and manage scoped keys in **Settings → API Keys**, or supply an existing key with direct API-key setup. Scoped and legacy API keys remain supported for CI, plugins, integrations, and automation through `LUA_API_KEY`, `.env`, or direct API-key setup. *** ## Scoped API keys **Released:** August 27, 2026 ### ✨ New Features Create personal API keys with an explicit role on specific organizations or agents, from **Settings → API Keys** in the admin dashboard — instead of a key that always carries your full account permissions. A scoped key can never do more than its owner can: its role on a resource is capped at the owner's current role there, and it loses access if the owner does (in that organization only — it keeps working in the owner's other organizations). An org admin can create a scoped key for another member, capped at that member's role. Existing keys are unaffected — they keep working exactly as before, with your full account permissions and no expiry. This is additive, not a migration. ```bash theme={null} Authorization: Bearer YOUR_API_KEY ``` See [API Keys](/concepts/api-keys) for the full picture: roles, expiry, rotate/suspend/revoke, and the new `403` response for a valid key whose role doesn't cover the action. *** ## v3.28.0 **Released:** August 30, 2026 ### ✨ New Features Email OTP setup can now create a credential limited to a selected organization, one or more agents, and an assignable role. ```bash theme={null} lua auth configure \ --email user@example.com \ --otp 123456 \ --org-id org_123 \ --agent-id agent_123 \ --role builder \ --name "local laptop" ``` Add an optional signing key to `LuaWebhook`. When configured, Lua verifies an HMAC-SHA256 signature over the exact request body before running the webhook. Existing webhooks without a key behave as before. ```typescript theme={null} export default new LuaWebhook({ name: 'order-updated', secret: 'replace-with-a-long-random-secret', execute: async (event) => ({ received: true }), }); ``` Templates can run a bundled skill tool, an agent instruction, or both after installation and before uninstall cleanup. Installed trigger callback URLs are also available through `LUA_TRIGGER_URL__`. A webhook trigger can run a named skill tool after verification and filtering without starting an agent conversation. Existing transform-only triggers continue to work unchanged. Organization administrators can list skills owned by their organization and transfer a skill to another organization they administer. ```bash theme={null} lua marketplace skill org lua marketplace skill transfer --marketplace-id skill_123 --new-org-id org_456 --force ``` ### 🔧 Improvements `lua integrations update` now replaces credentials only after authorization succeeds. Existing triggers, tool access, labels, attachments, and memory settings remain intact. Personal connections support the same flow with `--scope user`. `User.send()` now targets the authenticated user without a separate profile lookup. Existing code needs no changes. An existing connected account can satisfy a template requirement without another selection step. Template-installed agents can also use active attached connections consistently. Project initialization, agent listing, status, dashboard, and integration commands work cleanly with scoped credentials. CLI telemetry no longer stores account email or name. ### 🐛 Bug Fixes Fixed an upload compatibility issue that could make `lua push` fail while uploading compiled bundles. Cancelling or failing an integration reauthorization no longer disconnects the working account. Webhook-triggered turns now use the trigger's bound user, so user-specific actions reach the intended inbox. *** ## v3.27.0 **Released:** August 25, 2026 ### ✨ New Features `lua integrations connect` now asks who should own the connection. ```bash theme={null} # Interactive — asks, with "this agent only" pre-selected lua integrations connect # Non-interactive lua integrations connect --scope user --integration github --auth-method oauth --scopes all ``` **This agent only** is what the command has always done: the agent holds the credential and loses it when the agent goes. **Me** makes it yours. Every *private* agent you own can use it — including agents you create later, which are added automatically. Publishing an agent removes its access. A non-interactive run without `--scope` still connects to the agent, so existing scripts keep their meaning. ```bash theme={null} lua integrations list --scope user # yours, and how many agents each reaches lua integrations list --scope all # yours and this agent's lua integrations disconnect --scope user --connection-id ``` Disconnecting removes it from every agent at once. What it already added to memory is **kept** unless you choose to forget it — disconnecting and forgetting are separate decisions. Already connected something to an agent and want it available everywhere? Convert it instead of reconnecting: ```bash theme={null} lua integrations convert --connection-id lua integrations convert --connection-id --force # skip the prompt, for scripts ``` The agent you connected it to keeps access and its triggers keep firing. It asks for confirmation first, because there is no way to convert back. ### 🔧 Improvements Connecting as yourself has no wake-up triggers, no account name and no sensitive-data toggle. Passing `--triggers`, `--custom-webhook`, `--hook-url`, `--account-label` or `--hide-sensitive` with `--scope user` now fails immediately, naming the flags — rather than partway through a browser sign-in. The interactive prompts for those steps are skipped in that scope too. Integrations that connect for a whole workspace, memory-source connectors, and types you have already connected are left out of the list. If the current agent already has its own connection of that type, you are warned before a second one is created — it adds a credential, it does not replace the first. The `Channels.send` typings now describe addressing a shared Teams conversation with `conversationId`, alongside the existing per-person options. *** ## v3.26.0 **Released:** August 24, 2026 ### ✨ New Features One command writes a reviewable `template:` section into `lua.skill.yaml`, inferred from your agent: the connections its skills use, `{{variable}}` slots detected in the persona, trigger presets with their current on/off state as recommended defaults, and display metadata for install-time parameters. ```bash theme={null} lua marketplace template draft --template-id tpl_abc # review & edit lua.skill.yaml, then: lua marketplace template publish --template-id tpl_abc ``` Re-running `draft` merges additively — newly inferred entries are appended and your hand-edits are never modified — and prints a per-section added/kept diff. Pass `--force` to replace the authored sections wholesale, and `--source-version ` to compose from a specific promoted agent version. See [Publishing Templates](/marketplace/publishing-templates). When `lua.skill.yaml` carries a `template:` section, `publish` always serializes all four authored sections (`connections`, `personaTemplate`, `triggerPresets`, `paramsMeta`) — an empty or absent section is an **explicit clear**, never "inherit the previous version's". A publish that would clear or narrow a section prints a per-section consequence diff and asks for confirmation: ```bash theme={null} # Auto-confirm only the clear/narrow consequence prompt (nothing else) lua marketplace template publish --template-id tpl_abc --yes ``` Template versions are validated server-side at publish: undeclared persona `{{variables}}`, invalid schedule intervals, unknown connection platforms or scopes, duplicate keys, and secrets in frozen code all reject with actionable per-field errors — and a rejected publish burns no version number. Publishing from a source agent with voices or device triggers is rejected with a message saying why (voice agents can't be templated yet). A template can now be deployed from the marketplace straight into a fresh agent: the persona template's `{{variables}}` are filled in from your answers, connections you've already granted are reused automatically, and automations only arm after an explicit confirmation. See [Deploying Templates](/marketplace/deploying-templates). Call any endpoint a provider documents through the integration your agent is already connected to — from tools, jobs, webhooks, and processors. The call is relayed server-side over the agent's own connection (your code never sees credentials), and the response comes back as a raw `{ status, headers, data }` envelope: provider errors such as a `403` on a missing OAuth scope are relayed faithfully in `status`, never thrown. Every connected integration also gains a matching `{integration}_passthrough` agent tool. ```typescript theme={null} import { Integrations } from 'lua-cli'; const res = await Integrations.passthrough('github', { method: 'GET', path: 'repos/acme/app/pulls/42/files', }); if (res.status === 200) console.log(res.data); // files with per-file patches ``` Guardrails are built in: a per-integration enable switch admins can turn off, an audit log entry for every call, and a per-agent rate limit. See the [Integrations API](/api/integrations) for the full request/response reference, the typed error codes, and Microsoft Graph + GitHub examples. ### 🔧 Improvements The env-contract check on template install and apply is enforced server-side and can no longer be bypassed. The flag is still accepted so existing scripts don't break, but it does nothing beyond printing a deprecation warning — satisfy the contract with [`lua env`](/cli/env-command) or `--env-vars` instead. ## v3.25.0 **Released:** August 19, 2026 ### ✨ New Features `LuaAgent` accepts an optional `description` — a short capability summary that [Spaces](/overview/spaces) read when deciding which member agent to hand a request to: ```typescript theme={null} import { LuaAgent } from 'lua-cli'; export default new LuaAgent({ name: 'refunds-agent', description: 'Handles refunds, returns and order cancellations for existing orders.', persona: 'You are a refunds specialist...', skills: [refundsSkill], }); ``` Keep it to one or two sentences about **what the agent can do**. The persona still owns voice, tone and detailed instructions — the description is only the routing signal. It travels with the rest of your configuration on `lua push`: ```bash theme={null} lua push # 🧭 Pushing routing description... # ✅ Routing description pushed ``` Ownership is opt-in. Omit `description` and a value set in the dashboard is left untouched, so existing projects are unaffected. Set it to an empty string to clear it. Your agent can ask for an approval, flag a broken integration, or leave a notice in a user's inbox — from a tool, a [job](/api/luajob), a [webhook](/api/luawebhook), or a local run: ```typescript theme={null} import { User } from 'lua-cli'; const receipt = await User.Inbox.push({ title: 'Approve the Q3 renewal quote', body: 'Northstar Ltd renewal is ready to send at $48,000.', actions: ['approve'], key: 'northstar-q3-renewal', }); if (receipt.outcome === 'capped') { // Daily limit reached — fold it into your run summary instead. } ``` Cards with options resolve in one click, reusing the same `key` lands on the existing card instead of knocking twice, and reaching the daily limit returns `{ outcome: 'capped' }` rather than throwing. See the [Inbox API reference](/api/inbox) for card kinds, limits and the full receipt contract. *** ## v3.24.0 ### ✨ New Features **Declared indexes for the Data primitive** — filtered queries on large collections no longer degrade silently. Declare the fields you filter on where you store data, and the platform builds and maintains an agent-scoped database index for them: ```typescript theme={null} await Data.create('inference_cache', doc, { index: ['business_id'] }); // compound (filtered together — note the nested array): await Data.create('orders', doc, { index: [['country', 'business_id']] }); ``` * `Data.create()` / `Data.update()` / `patch()` accept `{ searchText?, index? }` (the legacy positional `searchText` string still works) * **New `Data.collections()`** lists your collections with entry counts and index status (`pending | building | ready | failed | rejected` + reasons) * Indexes build within minutes, stay alive while your agent uses them (writes or filtered reads), and are removed automatically \~14 days after all usage stops — no cleanup code * Limits: 2 fields per index, 3 declarations per call, 5 indexes per agent; invalid declarations are rejected visibly, never silently trimmed * Filtered queries that exceed the time budget on an **undeclared** field now fail with an error naming the collection, the field, and the exact declaration to add — instead of an opaque 502 See the [Data API — Indexes](/api/data#indexes) section for the full guide. **Correction (August 26, 2026):** the options-object third argument (`{ searchText?, index? }`) described above currently works in **local development runs only**. In a deployed agent, `Data.create()` and `Data.update()` accept a plain `searchText` string as the third argument, and passing an object fails with `searchText must be a string`. Keep deployed code on the string form until index declarations are supported in the deployed runtime — the [Data API — Indexes](/api/data#indexes) section tracks the current state. ## v3.23.2 **Released:** August 14, 2026 ### 🐛 Bug Fixes Use `--user` with a user ID, email address, or mobile number when you have organization management permission for the agent: ```bash theme={null} lua chat clear --user user@example.com --force ``` The target must be a user associated with that agent. Without `--user`, the command continues to clear only your own conversation history. *** ## v3.23.0 **Released:** August 7, 2026 ### ✨ New Features A user resolved by ID, email, or phone now stays attached to that instance for every mutation: ```typescript theme={null} const user = await User.get({ email: 'customer@example.com' }); await user.patch({ set: { onboardingStep: 'verified' }, unset: ['rep_code'], }); await user.unset('temporaryFlag'); ``` `update()`, `save()`, and `clear()` are target-bound too. Existing `User.get()` calls without an identifier still use the current conversation user, and `update()` keeps its merge behavior. Change and remove top-level fields without replacing an entire entry: ```typescript theme={null} const entry = await Data.getEntry('customers', entryId); await entry.patch({ set: { status: 'active' }, unset: ['legacyCode'], searchText: null, }); await entry.delete(); ``` `searchText: null` also removes the entry's semantic-search text. ### 🐛 Bug Fixes User instances resolved by user ID, email, or phone no longer fall back to the calling session's record when you update, save, patch, unset, or clear them. `LuaJob.timeout` accepts whole seconds from 1 through 600 and defaults to 300 seconds. Connection and reconnection attempts now verify that the browser callback belongs to the authorization attempt and intended agent. *** ## v3.22.0 **Released:** July 28, 2026 ### ✨ New Features `LuaSkill` now accepts the same optional `condition` as `LuaTool`, one level up: ```typescript theme={null} export default new LuaSkill({ name: 'loyalty-rewards', description: 'Loyalty points balance and reward redemption', context: ` - check_points: Use when the customer asks about their balance. - redeem_points: Use when the customer wants to spend points. `, condition: async () => { const user = await User.get(); return user.data?.loyaltyEnrolled === true; }, tools: [new CheckPointsTool(), new RedeemPointsTool()], }); ``` When it returns `false`, the skill's tools can't be called **and** the skill's name, context, and tool names are left out of the agent's prompt entirely — the agent doesn't know the capability exists. A tool-level condition only makes a tool uncallable; the skill's name and context stay in the prompt, so the agent can still say "…but you're not enrolled, so I can't do that". Reach for the skill-level gate when the existence of a feature is itself sensitive: tiering, entitlements, per-customer capabilities. Evaluated per message, per user, with the full Platform API available. Fail-closed — a condition that throws or times out hides the skill. Skills without a condition are unaffected. See [Conditional Skills](/api/luaskill#conditional-skills). Template header values now accept a Meta media id as well as a public URL: ```typescript theme={null} await Templates.send({ to: { phoneNumber: "+15551234567" }, templateName: "spring_promo", values: { header: { image_id: "1234567890" } }, }); ``` Upload the asset to Meta once and reuse the id across a campaign, instead of having the image re-fetched for every recipient. `image_id`, `video_id` and `document_id` sit alongside the existing `image_url` / `video_url` / `document_url` keys — an id is used in preference to a URL when both are given, and `document_filename` still applies either way. ### 🐛 Bug Fixes `async condition() { ... }` now behaves the same as `condition: async () => { ... }`. Previously the condition was dropped and the skill failed to build. A skill defined as a class alongside its tools now builds correctly and stays small — tool code no longer runs when the skill's condition is evaluated. *** ## v3.21.0 **Released:** July 24, 2026 ### ✨ New Features Publish a versioned snapshot of an agent's entire configuration (skills, webhooks, jobs, processors, triggers, model, plus a declared env-var contract) and install or roll it out to other agents. Includes fleet rollout across many agents with a per-target result table, an install ledger, consent-based creator updates, rollback via `lua version promote`, and manifest inspection. ```bash theme={null} lua marketplace template apply --template-id my-template-123 --agents agent-a,agent-b,agent-c --force ``` Templates never touch a target agent's persona, environment variable values, or channels. List a skill with `--visibility private` to make it visible and installable only within your organization. ### 🔧 Improvements `lua marketplace [skill|template] ` replaces the previous `create`/`install` role menus with one flat action namespace per domain. Two actions were renamed: viewing your own listings is now `mine`, and editing listing metadata is now `edit`. See the marketplace command reference for the full old → new migration table. Flags like `lua marketplace template view --version 2` previously printed the CLI's own version instead of selecting a version. The CLI's version flag is now `-V` / `--cli-version` (bare `lua --version` still works). ### 🔄 Changes Clearing another user's conversation history is no longer supported. The command now clears only your own history and fails with a clear message if `--user` is passed. *** ## v3.20.0 **Released:** July 21, 2026 ### ✨ New Features See, per primitive, which version is pinned by the active agent version versus what's in your local project. ```bash theme={null} lua version status ``` Mismatches are flagged with direction-aware guidance: primitives that are pushed but not yet live point you to `lua version create` + `lua version promote` (or `lua deploy ` for a single primitive), while local files older than what's live get a `lua sync` recommendation — with a warning that deploying older files would roll production back. React to a WhatsApp message with an emoji from your agent code: ```typescript theme={null} await Channels.whatsapp.sendReaction({ to: { phoneNumber: '+15551234567' }, messageId: 'wamid.HBgL...', emoji: '👍', }); ``` ### 🔧 Improvements For agents using agent versioning, `lua deploy` now automatically creates and promotes a new agent version scoped to the deployed primitive — the deploy is live immediately and shows up in `lua version list`. The success output includes the promoted version, and if the live version can't be updated the deploy fails with a clear message instead of reporting success. Connection listings now indicate when a connection has been paused. *** ## v3.19.0 **Released:** July 3, 2026 ### ✨ New Features Chat requests now accept an optional `clientContext.timezone` field — an IANA timezone string. When provided, the agent uses it as the user's local timezone for date/time-aware responses; when omitted it falls back to the user's stored profile, country, or UTC. ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "What time should I schedule my call for tomorrow morning?" } ], "clientContext": { "timezone": "Africa/Nairobi" } }' ``` Also available on `Agents.invoke`. See [HTTP API](/channels/http-api) and [Agents API](/api/agents). Set a default reasoning effort for your agent, normalized across every reasoning-capable provider (Claude, GPT/o-series, Gemini, Groq, DeepSeek, xAI, Qwen). ```typescript theme={null} import { LuaAgent } from 'lua-cli'; export const agent = new LuaAgent({ name: 'support-triage', persona: '...', modelSettings: { reasoning: { effort: 'low', show: false }, }, skills: [triageSkill], }); ``` `effort` is one of `'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'`. Left unset, it defaults to adaptive reasoning where the model supports it and an explicit low effort otherwise, favoring lower cost/latency on turns that don't ask for deeper thinking. `show` controls whether the reasoning trace is surfaced to the caller (default `true`). A per-request reasoning override always takes precedence over this agent-level default. See [LuaAgent → modelSettings](/api/luaagent) and [Model Selection → Reasoning Effort](/overview/model-selection#reasoning-effort). Resolve a colleague by name within your organization and get back the channel handles (WhatsApp, SMS, email) they've chosen to share. Pair it with `Channels.send` to message them directly — no hard-coded phone numbers. ```typescript theme={null} import { Team, Channels } from 'lua-cli'; const { matches } = await Team.findMember('Stefan'); const whatsapp = matches[0]?.targets.find((t) => t.channel === 'whatsapp'); if (whatsapp) { await Channels.send({ channel: 'whatsapp', to: { phoneNumber: whatsapp.value }, text: 'Hi Stefan!' }); } ``` Returns a list of matches so an ambiguous name can be disambiguated — each match includes its shareable `targets[]` (empty if the teammate hasn't opted in to sharing anything). Turn on `browser: true` to give your agent browser tools like `searchWeb`, routed to the user's connected desktop browser when available, or a cloud fallback otherwise. ```typescript theme={null} import { LuaAgent } from 'lua-cli'; export const agent = new LuaAgent({ name: 'research-assistant', persona: '...', browser: true, }); ``` Pass an object instead of `true` for policy: `engine` (`'auto'` default, `'agent-browser'` for local-only, `'browser-use'` for cloud-only), `allowedDomains`, `credentials` (named vault entries — never raw secrets), and `maxSessionMinutes`. Off by default — a browser session costs money and carries risk, so it's opt-in. ### 🐛 Bug Fixes `version create` could previously refuse with "no staged changes" even when you had real changes to snapshot, or silently accept a run with nothing to snapshot. It now diffs the actual content against the previous version — including a rename with no other change. When there's genuinely nothing new, it now tells you so plainly instead of throwing an error. `lua version diff` also shows voice changes, and the "--auto-deploy was ignored" notice now appears in the final push summary, not just at the very top. *** ## v3.18.0 **Released:** June 22, 2026 ### ✨ New Features Your agent can now **reach out first** — send messages on any connected channel from tools, jobs, webhooks, and triggers. Every send is recorded to the user's conversation thread, so the agent picks up with full context when they reply. ```typescript theme={null} import { Channels } from 'lua-cli'; // Free-form message on a connected channel await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Your order has shipped! 📦' }); // Rich email await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Receipt', html: '

Thanks for your order!

' }); // Approved WhatsApp template (start or re-open a conversation) await Channels.whatsapp.sendTemplate({ to: { phoneNumber: '+14155552671' }, templateName: 'order_update', languageCode: 'en_US' }); ``` See [Channels API](/api/channels) and [Proactive Messaging](/channels/proactive-messaging).
Define a trigger with a `verify` → `filter` → `transform` pipeline and get a pasteable URL that wakes your agent on any external event — no `execute` function required. ```typescript theme={null} import { defineTrigger } from 'lua-cli'; import { z } from 'zod'; export const orderCreated = defineTrigger({ name: 'order-created', description: 'Fires when a new order is created', inputSchema: z.object({ orderId: z.string() }), transform: (ctx) => `New order received: ${ctx.body.orderId}` }); ``` Manage triggers from the CLI: ```bash theme={null} lua triggers create --name order-created # prints the pasteable URL lua triggers list lua triggers logs --trigger order-created lua triggers rotate-token --trigger order-created ``` Prefer no code? `lua triggers create --name daily --instruction "Reply with today's date"` creates a URL trigger with no SDK. Let your agent send messages on its connected channels as part of a conversation by enabling the `outboundChannels` feature: ```bash theme={null} lua features configure --feature-name outboundChannels --recipient-scope anyone ``` Scope it to `current_user` (message only the person it's talking to) or `anyone` (message recipients it specifies). Drop specific tools from a voice agent, and transfer a live call to another in-room agent. ```typescript theme={null} // Exclude a tool from the voice agent excludeTools: ['place_order'] // Hand the live call to another agent await ctx.voice?.handoff('billing-agent'); ``` Send templates with media-rich headers. Create a template with `format: 'IMAGE'`, `'VIDEO'`, or `'DOCUMENT'` and provide a sample media URL for approval (must be publicly reachable HTTPS and within Meta's size/MIME limits). When sending, use `values.header.image_url` for IMAGE templates, `values.header.video_url` for VIDEO templates, or `values.header.document_url` (with optional `document_filename`) for DOCUMENT templates: ```typescript theme={null} // Send with video header await Templates.whatsapp.send(channelId, 'product_demo', { phoneNumbers: ['+447551166594'], values: { header: { video_url: 'https://example.com/product-demo.mp4' }, body: { product_name: 'Acme Widget' } } }); // Send with document header await Templates.whatsapp.send(channelId, 'invoice', { phoneNumbers: ['+447551166594'], values: { header: { document_url: 'https://example.com/invoice-12345.pdf', document_filename: 'invoice-12345.pdf' }, body: { order_number: '12345' } } }); ``` See the [Templates API](/api/templates#media-headers) for creation details and size limits.
### 🔧 Improvements Images embedded in an email body — for example pasted or dragged into Gmail compose — previously arrived as just a `[image: …]` text placeholder, with no image part. They are now forwarded to your agent as image parts, the same way paperclip attachments are. The rules: * The embedded image must be referenced in the email's HTML body and be at least 1 KB (spacer pixels are filtered out) * Up to 10 embedded images per email are forwarded; regular attachments are unaffected * Remote-hosted images (e.g. Gmail signature images, which are hosted rather than embedded) are not fetched * Attachments mislabeled as embedded content by some clients are detected and forwarded as normal attachments The `[image: …]` placeholder still appears in the message text — use the image part, not the placeholder. See [Email Channel → Attachments and Embedded Images](/channels/email#attachments-and-embedded-images). ## v3.17.2 **Released:** June 1, 2026 ### 🐛 Fixes `lua git auth github` now links your GitHub account reliably using GitHub's device flow — a code is shown in your terminal that you enter at [github.com/login/device](https://github.com/login/device). ```bash theme={null} lua git auth github # Open https://github.com/login/device and enter the code: WDJB-MJHT # ✓ Logged in to GitHub as @your-username. ``` See the [Git Command](/cli/git-command) documentation for the full workflow. ### ✨ New Features `lua git connect --auto-push` enables pushing each auto-commit to your linked GitHub repository. It checks that a GitHub account is linked and that your `origin` is a GitHub HTTPS remote before turning auto-push on, so a missing link or remote is reported immediately. ```bash theme={null} lua git auth github # link GitHub (once) git remote add origin https://github.com//.git lua git connect --auto-push # enable auto-commit + auto-push ``` The `git` block in `lua.skill.yaml` is managed by these commands — you no longer need to edit it by hand. See the [Git Command](/cli/git-command) documentation for details. ## v3.17.1 **Released:** May 29, 2026 ### ✨ New Features Set sampling settings once on the agent instead of overriding them in every skill. Supports `temperature`, `topP`, `topK`, `maxOutputTokens`, `presencePenalty`, `frequencyPenalty`, `stopSequences`, and `seed` — forwarded to the model on every chat turn. ```typescript theme={null} import { LuaAgent, type AgentModelSettings } from 'lua-cli'; export const agent = new LuaAgent({ name: 'role-scorer', persona: SCORING_PERSONA, modelSettings: { temperature: 0.2, maxOutputTokens: 4096, }, skills: [scoreRoleSkill], }); ``` Obviously-broken values (non-finite numbers, `temperature` outside `0..2`, `topP` outside `0..1`, non-positive `maxOutputTokens`, non-string `stopSequences`) are rejected at construction time. Provider-specific range checks are deferred to the provider. Constrain the model response to a JSON Schema. The parsed object lands on `result.output`. No more "respond with JSON only" prompts and no more `JSON.parse` boilerplate. ```typescript theme={null} import { AI } from 'lua-cli'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const Sentiment = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral']), score: z.number().min(0).max(1), }); const result = await AI.generate({ prompt: 'Analyze: "the food was incredible"', temperature: 0, structuredOutput: { schema: zodToJsonSchema(Sentiment) as Record, }, }); const parsed = Sentiment.parse(result.output); ``` On Google models, the auto-injected `google_search` tool is suppressed when `structuredOutput` is set (Vertex does not allow mixing function-calling tools with `google_search`). `AiGenerateInput`, `AiGenerateOutput`, `AiGenerateStructuredOutput`, `AgentModelSettings`, and sub-shapes (`AiGenerateJsonSchema`, `AiGenerateSource`, `AiGenerateToolCall`, `AiGenerateToolResult`) are now importable directly from `lua-cli`. ```typescript theme={null} import { AI, LuaAgent, type AiGenerateInput, type AiGenerateOutput, type AgentModelSettings, } from 'lua-cli'; const opts: AiGenerateInput = { prompt: 'Score this JD', structuredOutput: { schema: jdScoreSchema }, }; const result: AiGenerateOutput = await AI.generate(opts); ``` ## v3.5.0-alpha.2 ### 🔧 Improvements `Lua.request.webhook.payload` is now populated for inbound emails with a JMAP-aligned object containing parsed message metadata. ```typescript theme={null} import { Lua } from 'lua-cli'; const webhook = Lua.request.webhook; if (Lua.request.channel === 'email' && webhook) { const { messageId, inReplyTo, references, subject, from, to } = webhook.payload; // Use metadata to build threaded replies const replySubject = subject?.startsWith('Re:') ? subject : `Re: ${subject}`; const threadId = inReplyTo ?? messageId; return { threadId, replySubject }; } ``` Access the Message-ID for deduplication, full RFC 5322 header list via `headerLines`, and all threading metadata (`messageId`, `inReplyTo`, `references`). See the [API reference](/api/lua#email-metadata) for the complete shape and AgentMail divergence note. ## v3.16.0 **Released:** May 19, 2026 ### ✨ New Features Define voice agents in TypeScript alongside your `LuaAgent`. Supports cascaded (STT/LLM/TTS) and realtime (speech-to-speech) shapes, with hooks for `onEnter`, `onUserTurnCompleted`, `onExit`, and voice-only tools. ```typescript theme={null} import { LuaVoice } from 'lua-cli'; export default new LuaVoice({ name: 'support-line', llm: 'openai/gpt-5.1-chat-latest', stt: 'deepgram/nova-3', tts: 'elevenlabs/eleven_turbo_v2_5:pwMBn0SsmN1220Aorv15', turnDetection: 'vad', greeting: "Hi, this is your support line — how can I help?", }); ``` Reference the voice from your `LuaAgent`'s `voices: [supportVoice]`. `lua push` ships it through the same versioned-primitive flow as skills and webhooks. Buy, bind, list, and release phone numbers from the CLI: ```bash theme={null} lua channels # → ☎️ Manage phone numbers # → Search available numbers / Purchase / Bind / List / Release ``` SMS-capable numbers route through Vonage; voice-only routes through LiveKit-native PSTN. The bind action wires the number directly to your agent for inbound calls. Test voice agents with text input and event-stream assertions, runnable in CI: ```typescript theme={null} import { runVoice, expectContainsMessage, expectCalledTool } from 'lua-cli/voice/test'; import supportVoice from '../src/voices/support.voice'; test('greets and books appointment', async () => { const result = await runVoice(supportVoice, { turns: ['I need to book an appointment', 'Tomorrow at 2pm'], }); expectContainsMessage(result, 'appointment'); expectCalledTool(result, 'create_booking'); }); ``` Talk to your voice agent during development without going through a phone number: ```bash theme={null} lua voice terminal # CLI mode lua voice browser # Opens an embedded UI in your browser ``` View voice call logs from the CLI: ```bash theme={null} lua logs --type calls ``` Also available as a choice in the interactive `lua logs` picker. Re-exports of LiveKit's plugin namespaces from `lua-cli` so you don't need a direct LiveKit dependency in your project: ```typescript theme={null} import { LuaVoice } from 'lua-cli'; import { deepgram, elevenlabs, inference } from 'lua-cli/voice'; export default new LuaVoice({ stt: new deepgram.STT({ model: 'nova-3', language: 'en' }), tts: new elevenlabs.TTS({ model: 'eleven_turbo_v2_5', voice: '...' }), llm: new inference.LLM({ model: 'openai/gpt-5.1-chat-latest' }), }); ``` Factor shared tool / skill / webhook / etc. logic into a base class in a workspace package, then extend per-agent with field initializers: ```typescript theme={null} // shared/SharedSearchTool.ts export abstract class SharedSearchTool implements LuaTool { name = 'search'; ragSearchPath: string = '/default'; async execute(input: { query: string }) { return { results: await fetch(this.ragSearchPath, /* ... */) }; } } // agents/customer-a/src/tools/SearchTool.ts export class CustomerASearchTool extends SharedSearchTool { ragSearchPath = '/customer-a'; } ``` Works for every primitive type. The compiler walks the full `extends` chain, including transitive extends through shared intermediates. ### 🔧 Improvements Publishing a new voice version with `lua push voice` is enough — the next call uses the new version. No need to re-push the agent. ### 📝 Notes Voice agents require the latest server-side endpoints. Self-hosted Lua deployments must deploy the latest server before publishing voice agents via `lua push`. *** ## v3.15.3 **Released:** May 12, 2026 ### ✨ New Features After a primitive push, the CLI now also attaches the gzipped workspace archive to each skill's per-skill source store, so the Builder UI's source panel stays in sync with your CLI edits. ```bash theme={null} lua push skill # default: source attach included lua push skill --no-include-source # skip the attach (CI loops, etc.) lua push all # same default behavior ``` The attach is non-fatal — if it fails, the push is still considered successful, because the primitive is already deployed and the agent-level backup already succeeded. The per-skill store is a denormalized projection for the Builder UI, not the canonical source store. *** ## v3.15.2 **Released:** May 8, 2026 If you're upgrading from v3.15.1, upgrade directly to v3.15.2 (or later). v3.15.1 has a startup crash on fresh installs from npm. ### 🐛 Bug Fixes v3.15.1 shipped with a missing runtime dependency that crashed **every** CLI command on a fresh `npm install lua-cli@3.15.1`, not just the new `lua source` subcommands. v3.15.2 removes the dependency entirely — the affected paths now use the same HTTP layer as the rest of the CLI, so install size and dependency footprint are unchanged from v3.15.0. If you were stuck on v3.15.0 because of this, you can upgrade safely now: ```bash theme={null} npm install -g lua-cli@latest ``` *** ## v3.15.1 **Released:** May 7, 2026 This release has a startup crash on fresh installs from npm. Upgrade directly to v3.15.2 or later. ### ✨ New Features Two new subcommands for working with your agent's backup version history. **List versions:** ```bash theme={null} lua source list # most recent 50 versions lua source list --all # full history lua source list --limit 100 # custom cap ``` The currently active version is starred in the output. **Roll back to a past version:** ```bash theme={null} lua source rollback --version 12 # confirms before applying lua source rollback --version 12 --force # skip the confirmation ``` Rollback downloads the chosen version's files into your local workspace and then auto-pushes the rolled-back state as the next version. History is append-only — the original `v12` is never overwritten. After a rollback you have an explicit new version at the head representing "we returned to v12 on this date." `lua push ` now always runs a fresh-from-disk backup-push as the final step. If the backup fails, the command exits non-zero. No more silent partial success where the primitive landed on the server but your local source never reached the canonical store. This makes the CLI a reliable single source of truth for "this version was pushed from this machine in this state." Builds the backup manifest by walking your project directory directly from disk, instead of reading the compiled manifest. ```bash theme={null} lua push backup --fresh ``` Always-on for the auto-backup hook; opt-in for explicit `lua push backup` calls. Use this when files have been written by the Builder or by any other out-of-band path that bypassed `lua compile`. ### 🔧 Improvements Init's restore step now hits the active manifest endpoint, which reflects every successful push from any platform — CLI, Builder chat, dashboard edits. Previously, `lua init` against a Builder-managed agent could restore a backup days or weeks behind the actual runtime state because the Builder's writes weren't reaching the legacy backup manifest reliably. After `lua init` and after every successful `lua push`, the CLI records the server's active backup version into your local `lua.skill.yaml`. Foundation for future staleness warnings ("local is behind server") and for cross-machine drift recovery. The new fresh-from-disk walker enumerates source files directly, so files written by the Builder (or any out-of-band edit) make it into your backup. Per-file cap: 256 KB. Skipped: `node_modules`, `.git`, `dist`, `dist-v2`, `.env`, and lockfiles. Previously, the reconcile-only path re-hashed files already in the compiled manifest, missing anything written outside `lua compile`. ### 🐛 Bug Fixes Network timeouts or missing blobs during the post-init backup restore are now caught and logged. `lua init` continues with an empty workspace and tells you what failed, instead of aborting with an unhandled exception. The `dist-v2` directory is no longer accidentally pulled into fresh backups. Previously, every fresh backup included the entire compiled tree (bundles, per-file source copies, the manifest), inflating per-push size and contradicting the intent that compiled artifacts stay separate. *** ## v3.15.0 **Released:** May 6, 2026 ### ✨ New Features Clone an entire agent into a new one in a single command: ```bash theme={null} lua init --from-agent-id ``` The duplicated agent includes its persona, skills, MCP servers, env vars, and a full project backup that is restored locally so you can `lua push` immediately. The new agent inherits its source's LLM model — `lua init` no longer prompts for a model. Opt-in flags add more buckets to the copy: ```bash theme={null} lua init --from-agent-id \ --include-resources \ --include-custom-data \ --include-inquiry-form \ --include-devices \ --include-ecommerce-catalog ``` Cross-org duplicates are supported by adding `--org-id `. The interactive `lua init` flow (no flags) now offers "Duplicate an existing agent" as a third choice alongside "Create a new agent" and "Use one of your existing agents". Persona and skill context can now be split per channel using an object form. The previous string form keeps working — adopt the object form only where you need it. ```ts theme={null} new LuaAgent({ // string form (still works) persona: 'You are a helpful assistant…', // or split per channel persona: { base: 'Shared persona for all channels', voice: 'Speak naturally, no markdown, no bullet lists', text: 'Use markdown headers and bullet lists where helpful' }, }); ``` Useful when a voice agent needs different phrasing than the same agent's chat surface. The CLI pushes voice-only personas correctly (previously they could be skipped as empty), and `lua sync --pull` round-trips the object form back to your source file with all channel branches preserved. The new `PersonaText` type is exported from `lua-cli` for use in your own typings. ### 🔧 Improvements When a command hits a 401 from the server, `lua-cli` now distinguishes between two very different conditions and tailors the remediation hint accordingly: * **API key invalid or expired** — `Authentication failed. Run \`lua auth configure\` to set a new API key.\` * **API key valid, but no access to this agent** — `Access denied for this agent. Run \`lua agents\` to list agents you can access, or \`lua init\` to switch projects.\` Previous versions printed a single generic message for both, sending users to a dead-end remediation flow when their key was actually fine. Setting `priority` on a `LuaPostprocessor` now correctly ships to production. In previous versions, the field was silently dropped during compile, so postprocessors always ran in their default order regardless of declared priority. No code change needed — re-run `lua push` and your existing `priority` declarations now take effect. Restoring a project backup from an older agent no longer aborts mid-restore with `incorrect header check` when one of its stored blobs was archived without compression. Uncompressed blobs are now passed through verbatim. Affects `lua init --restore-sources`, `lua init --from-agent-id`, and any other path that restores a project backup. Two related fixes for the new channel-aware persona shape: * **Voice-only personas now push.** A persona of just `{ voice: '...' }` (no `base`, no `text`) is correctly applied. Earlier this was silently skipped as empty. * **`lua sync --pull` preserves all channel branches.** Pulling an object-form persona from the server and writing it back to local source no longer drops `voice` or `text`. ### 🐛 Bug Fixes `lua production` now correctly displays personas using the new object form, instead of crashing on `.length` / `.substring()` calls or printing the literal string `[object Object]`. Skills that don't define a `context` now push without error. Previously the CLI would send an empty string and the server would reject it under the new stricter persona/context validator. The source-write path for string-form personas now produces a properly-quoted literal under all edit paths, instead of occasionally emitting unquoted text that broke compilation. *** ## v3.14.0 **Released:** May 5, 2026 ### 🔧 Improvements `lua push` now reliably uploads agents of any size, including those with many tools or large code bundles. Previously, agents that exceeded the upload size limit would fail mid-push with a "request too large" error. This no longer happens — pushes succeed regardless of total bundle size. Applies to every primitive type: * skills (and their tools) * webhooks * jobs * preprocessors / postprocessors * devices and device triggers No configuration or command change required. Just run `lua push` as usual. Duplicate copies of code bundles have been removed from the push payload across all primitive types. Each push is now hundreds of KB to several MB smaller, which makes pushes faster — especially on slow connections. Sandbox sessions launched by `lua chat` benefit from the same reduction. No action needed; the change is automatic with this release. *** ## v3.13.0 **Released:** April 29, 2026 ### ✨ New Features A new `lua status` command (alias: `lua describe`) dumps the full state of your agent in one shot — no more running five separate commands to understand what's going on. ```bash theme={null} lua status # human-readable table lua describe # same output via alias lua status --json # machine-readable JSON (schemaVersion: 1) ``` What it shows: * **Environment** — CLI version, install method, Node version, API base, env overrides * **Updates** — current vs latest published version * **Auth** — key source, email, user ID, org list, server reachability * **Project** — config path, agent name/ID, manifest primitive count * **Primitives** — per-type sync table: local version, server version, status (synced / ahead / behind / not deployed) for skills, webhooks, jobs, preprocessors, postprocessors, MCP servers, devices, device triggers * **Persona** — synced / drifted * **Backup** — synced / out-of-sync * **Telemetry** — enabled/disabled * **Next steps** — actionable hints based on current state The `--json` flag outputs a stable JSON document (`schemaVersion: 1`) suitable for LLM agent consumption or CI dashboards. All progress output is suppressed in JSON mode. Mistyped commands now show a "Did you mean X?" suggestion instead of a bare error: ```bash theme={null} lua stauts # → error: unknown command 'stauts' (Did you mean: status?) lua pish # → error: unknown command 'pish' (Did you mean: push?) lua logss # → error: unknown command 'logss' (Did you mean: logs?) ``` Works for top-level commands and constrained argument values (log types, primitive kinds, environments) across all 22 commands. Common aliases are now accepted everywhere constrained argument values are expected: | Alias | Expands to | | ---------- | ------------------ | | `pp` | `postprocessor` | | `pre` | `preprocessor` | | `prod` | `production` | | `skills` | `skill` (plural) | | `webhooks` | `webhook` (plural) | | `jobs` | `job` (plural) | For example: `lua logs --type pp` is equivalent to `lua logs --type postprocessor`. Every push, deploy, chat, compile, sync, and test surface now provides contextual guidance after the operation: * **Error paths** — `💡 Diagnose: run \`lua logs --type X --name Y --limit 10\`\` pointing at the right log stream for what just failed. * **Success paths** — `✨ Tip: run \`lua logs --limit 10\`\` nudges you to verify production execution. * **`lua chat`** — after each conversation turn, silently probes for `agent_error` logs. If any fired during that turn, prints: `⚠️ N new agent error(s) — run \`lua logs --type agent\_error\` to inspect.\` * **`lua compile`** — tip to run `lua test` after a successful compile. * **`lua sync --push`** — tip to verify with `lua logs` after pushing. To suppress all hints (for CI scripts): `LUA_NO_HINTS=1 lua push all` Two improvements to `lua test` output: **Shape headers** — the tool return value is now prefixed with its type and field names: ``` Tool returned: Object — fields: success, message, data Tool returned: Array[3] of UserDataInstance — fields: id, name, email ``` **`--json` flag** — outputs pure JSON on stdout, with all progress and compile output redirected to stderr. Enables clean piping: ```bash theme={null} lua test skill --name myTool --input '{"userId":"123"}' --json | jq '.data' ``` *** ### 🐛 Bug Fixes Primitive arrays defined outside the agent config object previously caused primitives to be silently dropped from the compiled manifest. All of the following patterns now resolve correctly: ```typescript theme={null} // Variable reference const myTools = [new GetUserTool(), new UpdateUserTool()]; export default new LuaAgent({ tools: myTools }); // Spread export default new LuaAgent({ tools: [...baseTools, new CustomTool()] }); // Cross-file import import { sharedTools } from './shared/tools'; export default new LuaAgent({ tools: sharedTools }); // new ImportedClass() pattern import { GetUserTool } from './tools/GetUserTool'; export default new LuaAgent({ tools: [new GetUserTool()] }); ``` Previously, using any of these patterns would cause the compile to succeed (`✅ Compiled N primitives`) but with the primitives silently absent from the manifest — resulting in a broken agent after push. `lua push all` now automatically retries once when it encounters an "already exists" version collision. It re-fetches the current highest server version, bumps to the next one, and retries — resolving the most common `lua push all` failure without any user intervention. Additionally, sandbox versions (e.g. `1.0.21-sandbox`) are now correctly excluded when calculating the next production version bump. Primitives that exist on the server but not in your local YAML are now shown in interactive delete and trigger menus, clearly marked as `[server only]`. Previously these orphaned primitives were invisible in menus, making them impossible to delete or trigger interactively. Non-interactive delete (`lua preprocessors delete --preprocessor-name X`) also now falls back to server data for all primitive types. The orphan warning message now shows the exact delete command to use: ``` ⚠️ Found preprocessors on server not in your local code: - user-context To remove from server: lua preprocessors delete --preprocessor-name user-context To restore source from backup: lua sync --accept ``` `zod` is now bundled with the CLI. Previously, if your `node_modules/zod` was corrupted — by a partial install, a dependency conflict, or switching branches mid-install — every tool got an empty `inputSchema: {}` and the compile printed `✅ Compiled N primitives` anyway, shipping a broken agent silently. Now: * The bundled copy is used first and is immune to `node_modules` corruption. * A local `node_modules` fallback is tried second. * If both fail, compile aborts immediately with a clear reinstall hint before any primitive is processed. * **Monorepo backup** — `lua push backup` now captures source files imported from cross-package paths in a monorepo and restores them to `.lua/external/` on `lua sync --accept`. * **Log type `runtime`** — The log source previously called `mastra` is now `runtime`. Use `lua logs --type runtime` (was `--type mastra`). * **New log types** — `lua logs --type rag` and `lua logs --type device-trigger` are now valid filter values. * **Startup warnings eliminated** — Extraneous warnings that appeared on every CLI command have been removed. * **Skill sandbox stale ID** — Sandbox sessions that expire after 24 hours no longer show a misleading "run lua push first" message. The CLI now recovers automatically. * **`lua init` template fixes** — `Data.update` and `Products.search` usage in the scaffolded template was corrected. *** ## v3.12.3 **Released:** April 24, 2026 ### ✨ New Features lua-cli now installs three binary aliases pointing to the same entry point: `lua`, `heylua`, and `lua-ai`. Users who have the Lua programming language interpreter installed (which also claims the `lua` command) can run `heylua` or `lua-ai` without any conflict or post-install workaround. Existing scripts using `lua` continue to work unchanged. *** ## v3.12.2 **Released:** April 23, 2026 ### ✨ New Features Attach images and documents to any chat message using `@` syntax — in both interactive and non-interactive mode. ```bash theme={null} # Interactive mode @screenshot.png what's wrong with this UI? check @report.pdf and tell me what you see # Non-interactive mode lua chat -m "@screenshot.png what do you see?" -e production lua chat -m "compare @before.png and @after.png" -e sandbox ``` Images are sent as vision inputs; documents and text files are sent as file parts. The `@` token is stripped from the message text — only the file is forwarded alongside any remaining text. Supported types include: `.png`, `.jpg`, `.gif`, `.webp`, `.heic`, `.pdf`, `.docx`, `.xlsx`, `.ppt`, `.csv`, `.json`, `.html`, `.txt`, `.md`, `.eml`, and more. Files with unsupported extensions are left in your message as plain text. Email addresses (`user@example.com`) are never mistaken for file paths. Maximum attachment size: **10 MB** per file. Multiple attachments per message are supported. *** ## v3.12.1 **Released:** April 22, 2026 ### 🐛 Bug Fixes Agent creation previously ended with a 30-second blind sleep. That wait has been removed — `lua init` now completes immediately after the server responds. The agent persona is read directly from the create API response. The chosen model is now sent in the initial create request instead of a follow-up PATCH call, eliminating a window where the agent could briefly exist without a model. *** ## v3.12.0 **Released:** April 22, 2026 ### ✨ New Features `lua triggers` is now a top-level command (alias for `lua integrations webhooks`) that lets you manage your integration triggers directly from the CLI. New `pause` and `resume` subcommands let you suspend or restore triggers individually or for an entire connection: ```bash theme={null} lua triggers # interactive menu lua triggers list # list all triggers with status lua triggers pause --webhook-id # pause a single trigger lua triggers resume --webhook-id # resume a single trigger lua triggers pause --connection-id # pause all triggers on a connection lua triggers resume --connection-id # resume all triggers on a connection lua triggers pause --webhook-id --reason "Maintenance window" ``` The trigger list now displays rich status icons — ✅ active, ⏸️ paused, 💳 credit-suspended, 🔴 unhealthy — so you can see trigger health at a glance. The connect flow is also updated: triggers are opt-in by default (none pre-selected), giving you explicit control over which events wake your agent. ### 🐛 Bug Fixes Skills (`defineSkill`) were silently dropped from the compiled manifest because they were fed through esbuild like tools and webhooks. Skills are metadata-only (name, description, context, tool refs) and do not contain executable code. They now produce a JSON metadata artifact and flow through the full pipeline. Resolves the circular failure: `lua push skill` → "no server ID, run lua compile" → "not found in manifest". MCP server IDs are now written back to `lua.skill.yaml` immediately after a successful push, so the server is never treated as an orphan on its first deploy. Re-pushes are fully idempotent. *** ## v3.11.0 **Released:** April 21, 2026 ### ✨ New Features Added a reusable agent invocation surface callable from within a LuaSkill, LuaWebhook, LuaJob, or any other primitive: ```typescript theme={null} await Agents.invoke(targetAgentId, prompt) await Agents.invoke(targetAgentId, { messages, systemPrompt }) ``` Added support for device triggers as a first-class primitive decoupled from defineDevice, compiled and pushed like webhooks. Added `lua governance add` and `lua governance remove` commands to configure runtime enforcement of governance policies for your agents. ### 🐛 Bug Fixes Allowed underscores in device trigger names. Push-backup refusal message now shows project hashes instead of misleading timestamps. *** ## v3.10.0 **Released:** April 16, 2026 ### ✨ New Features Manage the LLM model for your agent directly from the CLI. ```bash theme={null} lua models # list all approved models lua models set # interactive model picker lua models set --model openai/gpt-4o # non-interactive lua models unset # revert to platform default lua models list --json # machine-readable output ``` `lua models list` shows all approved models grouped by provider, with the current model highlighted. `lua models set` writes the chosen model into your `src/index.ts` and syncs it to the server. `lua models unset` removes the model property and clears it on the server. Full CLI for managing devices connected to your agent. Devices are a new first-class primitive type that enables your agent to send commands to physical or virtual hardware and receive trigger payloads from them. ```bash theme={null} lua devices list [--group ] lua devices status --device-name lua devices enable --device-name lua devices disable --device-name lua devices remove --device-name [--force] lua devices test --device-name [--payload ] [--timeout ] lua devices test-trigger --device-name [--payload ] ``` Push device definitions with `lua push device` or include them in `lua push all`. Connect hardware or virtual devices using `@lua/device-client`. All actions support interactive mode — omit `--device-name` to choose from a picker. `lua sync --accept` now detects files you have modified locally since the last `lua push backup` and refuses to overwrite them. ```bash theme={null} lua sync --accept # guarded pull — fails if local changes detected lua sync --accept --force # bypass the guard and overwrite local changes ``` After every successful `lua push backup`, a local cache of file hashes is stored in `.lua/backup-manifest.json`. The guard compares this cache against your current files and only flags files that were actually modified. If no backup has been run yet, the pull proceeds with a warning rather than failing. New agents created via `lua init` now receive a structured persona template instead of `"Placeholder persona"`. The template includes suggested sections — identity, tone, audience, capabilities, boundaries, and guidelines — each with guidance notes to help you write an effective persona. It is designed to be reshaped or replaced entirely. ### 🐛 Bug Fixes The backup conflict detector used full 64-character SHA-256 hashes when reading files from disk, while the backup manifest stored 16-character truncated hashes (matching the compiler format). Every comparison failed, so every file appeared as a conflict regardless of whether anything had actually changed. Fixed by aligning the detector to use the same 16-char hash as the manifest. When a backup restore failed (network error, missing manifest), the CLI printed "Sync complete" and exited with code 0 — silently masking the failure. When drift included source-bearing primitives but no backup was available, the missing count was also never incremented. Both failure paths now correctly propagate the error so CI/CD pipelines can detect a failed pull. ### 🔧 Improvements Use `lua sync --accept --force` (or `lua sync --force`) to intentionally overwrite local changes when pulling from the server. *** ## v3.9.3 **Released:** April 15, 2026 ### ✨ New Features Choose your agent's LLM model during `lua init`. **Interactive mode:** a searchable, provider-grouped model list appears after org and name selection. Select a model or skip to use the server default. **Non-interactive mode:** ```bash theme={null} lua init --model openai/gpt-4o lua init --agent-name "My Bot" --org-id org123 --model anthropic/claude-3-5-sonnet ``` The selected model is written into your generated `src/index.ts` and synced to the server. Works correctly across fresh init, re-init, backup restore, and agent-switch flows. ### 🐛 Bug Fixes `lua sync --accept` and interactive pull for MCP servers were silently no-ops — servers created via the dashboard were never written to local YAML even when drift was detected. Fixed: MCP servers missing locally are now correctly added to YAML on pull. During `lua sync`, the push suggestion for MCP servers was `lua push mcpServer --name "X"` (invalid command). Fixed to `lua push mcp --name "X"`. ### 🔧 Improvements The option to use an existing agent now reads "Use one of your existing agents" instead of "Extend one of your existing agents". The word "extend" implied inheritance; the actual behavior is source restore or template scaffold. *** ## v3.9.0 **Released:** April 13, 2026 ### 🔧 Improvements `keytar` (OS keychain) has been removed. The CLI now works on headless Debian, Docker, and VMs without any native system dependencies. API key resolution order: 1. `LUA_API_KEY` environment variable 2. `~/.lua-cli/credentials` file (written by `lua auth configure`) 3. `.env` file values ```bash theme={null} # Option 1 — environment variable (CI/CD, Docker) export LUA_API_KEY=your-api-key # Option 2 — interactive setup (local dev) lua auth configure # Option 3 — .env file echo "LUA_API_KEY=your-api-key" >> .env ``` **Upgrading from v3.8.x or earlier?** Run `lua auth configure` once to store your key in the new location. Your previous key stored in the OS keychain is not migrated automatically. *** ## v3.8.0 **Released:** April 8, 2026 ### ✨ New Features Subscribe your webhooks to WhatsApp message lifecycle events dispatched when Meta sends status callbacks. ```bash theme={null} lua webhooks events list-events # See all subscribable event types lua webhooks events subscribe # Subscribe to events lua webhooks events unsubscribe # Remove subscriptions ``` Supported events: `sent`, `delivered`, `read`, `failed`, `played`. `lua deploy` now mirrors `lua push [type]` — deploy any primitive interactively or directly: ```bash theme={null} lua deploy # Interactive type selection lua deploy skill # Deploy a skill version lua deploy webhook # Deploy a webhook version lua deploy job # Deploy a job version lua deploy preprocessor # Deploy a preprocessor version lua deploy postprocessor # Deploy a postprocessor version lua deploy persona # Deploy a persona version lua deploy all --force # Deploy latest of everything ``` New generic flags: `--name` (replaces `--skill-name`) and `--set-version` (replaces `--skill-version`). Deprecated aliases kept for backwards compatibility. ### 🐛 Bug Fixes `lua jobs deploy -i myJob -v latest` now works correctly — `-i` and `-v` are registered as short flags for `--job-name` and `--job-version`. The activate/deactivate selection list now shows the live server status badge next to each job. Jobs, preprocessors, and postprocessors were comparing the wrong ID against `activeVersionId`, causing the active version to not be highlighted correctly. Fixed to use the version's own ID in all cases. Invalid event types passed to `lua webhooks events unsubscribe` now show a clear validation error instead of a misleading "not subscribed" message. *** ## v3.7.5 **Released:** March 30, 2026 ### ✨ New Features Agents can now have individual batching configuration for message debouncing. New `lua chat` flags for testing: ```bash theme={null} lua chat --batch # Enable message batching in chat session lua chat --delay 2000 # Set first-message delay (ms) ``` Batching config is set per-agent with fallback to environment variables. *** ## v3.7.4 **Released:** March 27, 2026 ### 🐛 Bug Fixes Fixed several issues that could cause API key saves to silently fail on macOS. The auth flow now correctly reports errors and clears stale data on re-authentication. `.env` parsing now correctly strips inline comments: `LUA_API_KEY=abc # my comment` resolves to `abc`. *** ## v3.7.3 **Released:** March 24, 2026 ### 🔧 Improvements Automatic retries with backoff for transient server failures (429, 500–504). Max 3 retries, never retries client errors. Improves reliability for flaky network conditions. Removed the `lua dev` web UI command and unused dependencies, significantly reducing install size. ### 🐛 Bug Fixes Chat sessions now have a 5-minute timeout instead of hanging indefinitely on unresponsive connections. *** ## v3.7.2 **Released:** March 24, 2026 ### ✨ New Features New `AI.generate` API for running text generation from within tools, aligned with Vercel AI SDK `generateText` semantics. **Simplified — returns plain text:** ```typescript theme={null} // Single argument: prompt is the user message const text = await AI.generate('Summarize the latest AI news.'); // Two arguments: system instruction + user content (string or multimodal parts) const text2 = await AI.generate( 'You are a helpful assistant.', [{ type: 'text', text: 'What products do you recommend?' }] ); ``` **Full options — returns rich result:** ```typescript theme={null} const result = await AI.generate({ model: 'google/gemini-2.0-flash', system: 'You are concise.', prompt: 'What is the weather in London?', }); result.text // Generated text result.finishReason // 'stop', 'length', etc. result.usage // { promptTokens, completionTokens, totalTokens } result.sources // Google Search grounding URLs (when available) ``` **Supported providers:** `google/*` (Vertex AI), `openai/*`, `anthropic/*` — with automatic fallback if the requested provider's API key is missing. Google Search grounding is automatically attached for Google models. ### 🔧 Improvements Keytar is now loaded on demand and skipped entirely when `LUA_SKIP_KEYCHAIN` is set. Fixes `MODULE_NOT_FOUND` errors in StackBlitz WebContainers and browser sandboxes. ```bash theme={null} export LUA_SKIP_KEYCHAIN=1 lua --version # Now works without keytar.node ``` *** ## v3.7.0 **Released:** March 20, 2026 ### ✨ New Features Use `lua chat -t` or `--thread ` for isolated sessions. `lua chat clear --thread ` clears one thread. Omit the thread ID with `-t` to auto-generate a UUID. ```bash theme={null} lua chat --thread my-test lua chat -t lua chat clear --thread my-test --force ``` `--clear` / `--clear-thread` clear history when the session ends—useful after testing without running `lua chat clear` separately. ```bash theme={null} lua chat --clear lua chat -t my-test --clear ``` ### 🐛 Bug Fixes Pushing after removing `model` from your agent now clears the server-side model (BAC-87). Help text and examples reference current feature names (e.g. `inquiry` instead of deprecated `tickets`). *** ## v3.6.7 **Released:** March 15, 2026 ### 🐛 Bug Fixes Connecting integrations defaults to exposing full MCP tool payloads (hide sensitive off). `defer_tools` is aligned with Unified.to expectations. Compiling skills resolves tool references more reliably for imports and dependencies. *** ## v3.6.6 **Released:** March 11, 2026 ### ✨ New Features Added `lua logs --type agent_error` to filter logs for execution errors in tools, webhooks, and jobs. ### 🐛 Bug Fixes Fixed timezone handling for cron schedules and improved error logging for failed cron jobs. *** ## v3.6.5 **Released:** March 10, 2026 ### 🔧 Improvements Extended base URLs for internal service discovery. *** ## v3.6.1 **Released:** March 2, 2026 ### 🐛 Bug Fixes `interval` and `once` job schedules now compile and push correctly. Previously, the `seconds` field (interval) and `executeAt` field (once) were silently dropped during compilation, causing `lua push job` to fail with: ``` Push Error: Interval seconds must be at least 60 ``` All three schedule types now work as documented: ```typescript theme={null} // Interval — seconds now correctly sent to server schedule: { type: 'interval', seconds: 300 } // Once — executeAt now correctly sent to server schedule: { type: 'once', executeAt: new Date(Date.now() + 3600000) } // Cron — was already working schedule: { type: 'cron', expression: '0 9 * * *' } ``` Agents that reference imported primitives using instantiation patterns in their config arrays (e.g. `jobs: [new MyJob()]`) now correctly resolve and compile. The CLI now exits immediately after completing a command. Previously, a background timer kept the process alive for up to \~1s after all work was done. *** ## v3.6.0 **Released:** February 27, 2026 ### ✨ New Features `LuaAgent` now supports a `model` property to control which AI model your agent uses. Specify a static model string or a dynamic resolver function. ```typescript theme={null} import { LuaAgent } from 'lua-cli'; // Static model export const agent = new LuaAgent({ name: 'my-agent', persona: 'You are a helpful assistant.', model: 'openai/gpt-4o', }); // Dynamic model based on channel export const agent = new LuaAgent({ name: 'my-agent', persona: 'You are a helpful assistant.', model: async (request) => { if (request.channel === 'whatsapp') return 'openai/gpt-4o-mini'; return 'openai/gpt-4o'; }, }); ``` Supported providers: `google/*`, `openai/*`, `anthropic/*`. Default: `google/gemini-2.5-flash`. lua-cli now collects usage data to help improve the developer experience. A new `lua telemetry` command lets you control data collection: ```bash theme={null} lua telemetry # Show current status lua telemetry on # Enable telemetry lua telemetry off # Disable telemetry ``` Or set `LUA_TELEMETRY=false` in your environment. See [Telemetry](/cli/utility-commands#lua-telemetry) for details. ### 🔧 Improvements `keytar` (OS keychain access) is now optional. Fixes installation on CI/CD systems without native build tools. Falls back to environment variables or `.env` file. *** ## v3.5.0 **Released:** February 20, 2026 ### ✨ New Features The `User.get()` method now supports looking up users by email address or phone number, in addition to userId. ```typescript theme={null} import { User } from 'lua-cli'; // Look up by email const user = await User.get({ email: 'customer@example.com' }); // Look up by phone (both formats work) const user = await User.get({ phone: '+1234567890' }); // Handle not found (returns null, doesn't throw) if (!user) { return { error: 'User not found' }; } ``` Useful for webhooks receiving contact info from external systems. Connect your agent to 250+ third-party services via [Unified.to](https://unified.to). When you connect an account, an MCP server is automatically created to expose tools to your agent. ```bash theme={null} lua integrations # Interactive mode lua integrations connect # Connect (OAuth or API token) lua integrations list # List connected accounts lua integrations webhooks # Manage webhook triggers lua integrations mcp # Manage MCP servers ``` **Key Features:** * OAuth and API token authentication * Automatic MCP server creation and activation per connection * Event-driven webhook triggers that wake up your agent * Server-side finalization for reliable connection setup See the [Integrations Command](/cli/integrations-command) documentation. Event-driven triggers that wake up your agent when events occur in connected services. ```bash theme={null} # Connect with triggers (all selected by default in interactive mode) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Use custom webhook URL instead of agent trigger lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created --custom-webhook --hook-url https://my-server.com/webhook ``` * Triggers pre-selected by default in interactive mode * Choose between "Agent wake-up" mode or custom webhook URLs * Friendly labels for OAuth scopes and webhook events * JSON output: `lua integrations webhooks list --json` Back up your project source files to cloud storage and restore them on any machine. ```bash theme={null} # Backup project sources lua push backup # Restore on new machine lua init --agent-id abc123 --restore-sources ``` **Key Features:** * Content-addressed storage with automatic deduplication * S3 direct upload — no file size limits * Efficient incremental backups (only changed files uploaded) * Full project recovery on new machines See the [Skill Management](/cli/skill-management#lua-push-backup) documentation. New global `--ci` flag makes the CLI fail loudly on missing required arguments instead of silently hanging in non-TTY environments. ```bash theme={null} # In CI/CD pipelines lua --ci push skill --name mySkill --set-version ${{ github.sha }} --force # In local development (interactive prompts work normally) lua push skill ``` See the [Non-Interactive Mode](/cli/non-interactive-mode#cicd-mode-ci-flag) documentation. New `lua update` command for self-updating from npm, plus a background outdated version warning on every command. ```bash theme={null} lua update # Force-fetch latest version and install ``` * Background version check with 24h file cache (zero latency impact) * Alpha users stay on alpha channel, stable users stay on latest * Boxed warning to stderr when outdated New `lua agents` command lists all organizations and agents you have access to. ```bash theme={null} lua agents # Formatted output lua agents --json # JSON output for scripting ``` See the [Utility Commands](/cli/utility-commands#lua-agents) documentation. You can now define primitive properties using variables and imports — not just inline literals. ```typescript theme={null} const MY_SCHEDULE = { type: 'cron', expression: '0 9 * * *' }; const MY_TOOLS = [calculatorTool, weatherTool]; import { RETRY_CONFIG } from './config'; export const myJob = defineJob({ schedule: MY_SCHEDULE, // ✅ variable reference retry: RETRY_CONFIG, // ✅ imported constant execute: async () => { ... } }); ``` **Supported for:** job schedule/retry, skill tools arrays, MCP server resolver functions, and all agent config arrays. `lua push all` now pushes everything including persona and backup: ```bash theme={null} lua push all --force # All primitives + persona + backup lua push all --force --auto-deploy # Also deploys persona to production ``` Persona and backup failures are non-fatal — the rest of the push completes normally. The CLI now automatically adds `dist-v2/` to your `.gitignore`: - **New projects**: Included in `lua init` template - **Existing projects**: Added after first successful compilation - Idempotent and safe to run multiple times ````bash lua logs --type mcp # View MCP tool execution logs lua logs --type theme={null} mastra # View Mastra AI runtime logs lua logs --user-id user_123456 # Filter by user ID ``` ```bash lua configure --api-key YOUR_API_KEY # Direct setup lua configure --email user@example.com # Request OTP lua configure --email user@example.com --otp 123 # Verify OTP ``` ```bash lua persona sandbox view # Print persona and exit lua skills sandbox view # List local skills and exit ```` ### 🔧 Breaking Changes These changes may affect existing scripts and workflows. Please update accordingly. ```bash theme={null} # Old (no longer works) lua push skill --name mySkill --version 1.0.5 # New (correct) lua push skill --name mySkill --set-version 1.0.5 ``` **Reason:** Avoids confusion with the global `--version` flag that shows CLI version. ```bash theme={null} # Old behavior (sync ran by default) lua compile # Checked for drift # New behavior (sync is opt-in) lua compile # No drift check (fast) lua compile --sync # Enable drift check ``` **New flag:** `--verbose` for detailed compilation output. ### 🚀 Improvements `lua push --force` now automatically checks the server for the highest existing version. **Benefit:** Prevents "Version already exists" errors during automated deployments. `lua sync` now runs compilation first for more accurate drift detection. Chat now displays preprocessor block response text instead of showing empty responses. All errors now go through standardized formatting for clearer, more actionable messages. ### ⚡ Performance * **Before**: 6 sequential HTTP calls (\~3-6s) * **After**: All fetched in parallel (\~1s) One HTTP call per primitive type instead of one per entity. No upfront validation call. The first API call validates the key. ### 🐛 Bug Fixes * **Job Creation**: Fixed `__exports` naming collision when primitives called `Jobs.create()` at runtime * **Backup Size Limit**: S3 presigned uploads fix "request entity too large" for large projects * **MCP Server Duplicates**: Intelligent URL merging handles concurrent MCP creation flows * **Email Channels**: Aligned with updated API schema (mode selection, displayName, response types) * **Agent Config Arrays**: Defining `jobs: MY_JOBS` via variables no longer silently empties the agent * **Webhook headerSchema**: Header validation schemas now correctly pushed to server * **Tool Conditions**: Tools without `condition()` no longer fail at runtime * **MCP Server Push**: Fixed detection, manifest metadata, and push pipeline (3 bugs) * **Auth Errors**: `AuthenticationError` properly propagated; 403 Forbidden handling added * **Compile Sync**: Handlers no longer overwrite each other's IDs * **Marketplace**: Only shows skills with published and approved versions * **Test Command**: Fixed preprocessor test handling for object format ### 📝 Interface Updates * Added `UserLookupOptions` for email/phone lookup * Added `EmailChannelMode`, `CreateGeneratedEmailChannelResponse`, `CreateExistingEmailChannelResponse` * Added `MCPServerSource` enum for tracking MCP server origin * Updated `User.get()` return type to `UserDataInstance | null` * Added `'mcp'` and `'mastra'` to log type enums *** ## v3.4.0 **Released:** January 22, 2026 ### ✨ New Features The `lua compile` command now uses a safer, non-destructive sync pattern. Instead of automatically deleting primitives on the server that aren't in your local code, it now: * **Warns** about orphaned primitives (skills, webhooks, jobs, MCP servers) * **Suggests** using explicit delete commands * **Filters** warnings to CLI-sourced skills only (ignores marketplace and manual skills) This prevents accidental data loss and gives you full control over what gets removed. Explicit delete commands for managing primitives no longer in your local code: ````bash # Delete a skill from the server lua skills delete --skill-name theme={null} my-old-skill # Delete a webhook lua webhooks delete --webhook-name old-webhook # Delete a job lua jobs delete --job-name deprecated-job ``` **Features:** - Server lookup for orphaned items not in local YAML - Interactive mode with confirmation prompts - `--force` flag for non-interactive deletion Added support for the modern Streamable HTTP transport (MCP spec 2025-03-26): ```typescript const server = new LuaMCPServer({ name: 'my-api', transport: 'streamable-http', // New transport type url: 'https://mcp.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("API_KEY")}` }) }); ```` **Supported transports:** * `'streamable-http'` - Modern MCP standard (recommended) * `'sse'` - Legacy Server-Sent Events transport **stdio transport removed:** Local MCP servers using `stdio` transport are not supported yet. Use remote servers with `streamable-http` or `sse` instead. ### 🐛 Bug Fixes * **Push**: Fixed crash when pushing primitives without a version field. First push now defaults to version `0.0.1` and shows "(none - first push)" in prompts. ### 📝 Interface Updates * Added `MCPStreamableHttpServerConfig` interface for streamable-http transport * Updated `MCPTransport` type to `'sse' | 'streamable-http'` * Added `SkillSource` type: `'cli' | 'marketplace' | 'manual'` * Removed `MCPStdioServerConfig` (stdio not supported yet) *** ## v3.3.0 **Released:** January 20, 2026 ### ✨ New Features All CLI commands now support full non-interactive operation, enabling seamless automation for AI IDEs, CI/CD pipelines, and shell scripting. **Design Patterns:** * Consistent option naming: `---name` and `---version` * `--force` flag for skipping confirmation prompts * `--json` flag for machine-readable output * Action arguments for entity management (view, versions, deploy, activate, deactivate) ```bash theme={null} # Entity management lua skills view lua skills versions --skill-name mySkill lua skills deploy --skill-name mySkill --skill-version 1.0.3 # Init command lua init --agent-id abc123 lua init --agent-name "My Bot" --org-id org1 # Sync command lua sync --check # CI drift detection lua sync --accept # Auto-sync from server # Test command lua test skill --name get_weather --input '{"city": "London"}' # Logs command lua logs --type skill --name mySkill --limit 10 --json ``` See the [Non-Interactive Mode Guide](/cli/non-interactive-mode) for complete documentation. The logs command now supports filtering by user messages and agent responses: ````bash lua logs --type user_message --limit 20 lua logs --type agent_response theme={null} --limit 20 ``` - 💬 **User Messages**: View incoming user messages with channel and userId info - 🤖 **Agent Responses**: View AI-generated responses with distinct coloring MCP server configurations now support dynamic environment variable resolution using the `env()` API: ```typescript const mcpServer = new LuaMCPServer({ name: 'mongo-mcp', transport: 'stdio', command: 'npx', args: ['-y', '@mongodb/mcp-server'], env: () => ({ MDB_CONNECTION_STRING: env("MDB_CONNECTION_STRING") }) }); ```` Also supported for SSE transport with `url` and `headers` resolver functions. Added filter support to `Products.get()` with backward compatibility: ```typescript theme={null} // Backward compatible const products = await Products.get(1, 10); // New approach with filters const products = await Products.get({ page: 1, limit: 10, filter: { category: 'electronics', inStock: true } }); ``` ### 🐛 Bug Fixes * **Chat**: Allow `lua chat` to work without mandatory skills - agents can now have only webhooks, jobs, or processors * **Commands**: Normalized action handling for case-insensitive action comparisons ### 🔧 Improvements * **Chat Command**: Informational message when defaulting to sandbox environment * **Chat Command**: Improved visual separation between compile logs and chat response * **Env Command**: Proper error handling for save/delete operations * **Push Command**: Shared helper functions reduce code duplication *** ## v3.2.0 **Released:** January 13, 2026 ### ✨ New Features New `lua sync` command to detect drift between server and local code: ```bash theme={null} lua sync ``` **Features:** * Compare agent name and persona between server state and local code * Fetch latest published persona version (excludes drafts and rollbacks) * Show colored line-by-line diff for easy comparison * Interactive resolution: update local from server or continue with local * Integrated into compile flow with `--no-sync` and `--force-sync` flags New `lua chat clear` command to clear conversation history: ```bash theme={null} # Clear all conversation history lua chat clear # Clear history for a specific user lua chat clear --user user@example.com ``` Accepts userId, email, or mobile number as the identifier. New `Lua` namespace for runtime access: ```typescript theme={null} import { Lua } from 'lua-cli'; // Access channel information const channel = Lua.request.channel; // 'dev', 'webchat', 'whatsapp', etc. ``` Channel is typed as a union type: `'dev' | 'webchat' | 'whatsapp' | 'messenger' | 'voice' | 'api' | 'email'` Access raw webhook payloads in tool execute functions: ```typescript theme={null} import { Lua } from 'lua-cli'; // Access raw webhook payload when triggered by webhook const webhookPayload = Lua.request.webhook; ``` Browse marketplace skills now supports pagination: * Navigate through pages with Previous/Next options * Shows page info (Page X/Y) and total count * Configurable page size (default: 10 items) ### 🔧 Improvements * **Simplified Agent Creation**: Streamlined `lua init` flow with cleaner prompts * **Better TypeScript Support**: Improved handling of path aliases and variable references in your code ### 🐛 Bug Fixes * Fixed sync command occasionally showing false drift detection * Fixed skill publishing issues * Fixed compilation when skills are defined inline vs imported from separate files *** ## v3.1.0 **Released:** December 7, 2025 ### ✨ New Features New `LuaMCPServer` class for integrating Model Context Protocol servers with your agent: ```typescript theme={null} import { LuaMCPServer, LuaAgent } from 'lua-cli'; const docsServer = new LuaMCPServer({ name: 'lua-docs', transport: 'sse', url: 'https://docs.heylua.ai/mcp/sse' }); export const agent = new LuaAgent({ mcpServers: [docsServer] }); ``` **CLI commands:** * `lua mcp` - List, activate, deactivate, or delete MCP servers * `lua push mcp` - Push individual MCP servers * MCP servers included in `lua push all --force` Tools can now have a `condition` function that determines if the tool is available: ```typescript theme={null} const adminTool = new LuaTool({ name: 'admin-tool', condition: async () => { const user = await User.get(); return user.data?.isAdmin === true; }, execute: async (input) => { return 'admin action completed'; } }); ``` Use conditions to dynamically enable/disable tools based on user subscription, verification status, feature flags, or region. New `CDN` namespace for uploading and retrieving files: ```typescript theme={null} import { CDN } from 'lua-cli'; // Upload a file const file = new File([buffer], 'image.png', { type: 'image/png' }); const fileId = await CDN.upload(file); // Get file const file = await CDN.get(fileId); ``` New methods for job management: ```typescript theme={null} // Get all jobs const jobs = await Jobs.getAll(); // Activate/deactivate job scheduling await job.activate(); await job.deactivate(); // Manually trigger execution await job.trigger(); ``` New `lua evals` command opens the Evaluations Dashboard with your agent pre-configured. `bash lua evals ` New `Templates` namespace for WhatsApp template messaging: ```typescript theme={null} import { Templates } from 'lua-cli'; // List templates const result = await Templates.whatsapp.list(channelId); // Send template message await Templates.whatsapp.send(channelId, templateId, { phoneNumbers: ['+447551166594'], values: { body: { name: 'John' } } }); ``` New `lua marketplace` command for discovering, installing, and publishing skills: **For Creators:** - Publish skills to the global marketplace - Version management with semantic versioning - Environment variable configuration per version **For Installers:** - Browse and search for verified skills - Smart installation with dependency checks - Interactive environment variable configuration The `lua logs` command now features: - Interactive filtering by primitive type (Skills, Jobs, Webhooks, etc.) - Live data from API including dynamically created jobs - Context-aware log display with detailed metadata New `user._luaProfile` property for read-only core user data: ```typescript theme={null} const userId = user._luaProfile.userId; // ✅ Recommended const fullName = user._luaProfile.fullName; const emails = user._luaProfile.emailAddresses; // user.userId still works but is deprecated ``` ### 💥 Breaking Changes These changes may require updates to your existing code. `JobInstance` now receives the full `Job` entity with `activeVersion`: ```typescript theme={null} // Before const schedule = job.schedule; const id = job.jobId; // After const schedule = job.activeVersion.schedule; const id = job.id; await job.trigger(); // New method ``` The `welcomeMessage` field has been removed from `LuaAgent` configuration: - For voice: use `voiceConfig.welcomeMessage` - For chat widgets: use `WebchatChannelConfig.welcomeMessage` Webhook execute functions now receive a single event object: ```typescript theme={null} // Before execute: async (query, headers, body) => { // handle webhook } // After execute: async (event) => { const { query, headers, body, timestamp } = event; // handle webhook } ``` `PreProcessorResult` now uses a discriminated union: ```typescript theme={null} // Block response return { action: 'block', response: 'Blocked' }; // Proceed response return { action: 'proceed', modifiedMessage: [{ type: 'text', text: '...' }] }; ``` * `modifiedMessage` is now `ChatMessage[]` (array) * Added `priority` field for execution order * Removed `context` field ### 🔧 Improvements * **Data API Type Safety**: `searchText` parameter added to `Data.create()` and `Data.update()`, `data` parameter type changed to `Record` * **PostProcessor Simplified**: Return type now requires `modifiedResponse: string`, removed `async` field * **Compilation**: Handle `.js` extensions for Node16/NodeNext module resolution * **Template**: Minimal by default, use `--with-examples` flag for examples * **Web UI**: React Query, Sonner toasts, improved env panels, docs in toolbar ### 🛠️ Refactoring * Removed `context` field from webhooks, jobs, and postprocessors * Removed `version` field from `LuaSkill`, `LuaJob`, and processor configurations * Improved push command: displays both `webhookId` and `webhook-name` URL formats * Rewritten `interfaces/jobs.ts` to match `lua-api` DTOs exactly *** ## v3.0.3 **Released:** October 30, 2025 ### 🎯 User API Enhancement Enhanced `User.get()` method now accepts an optional `userId` parameter: ```typescript theme={null} // Get current user (existing behavior) const userData = await User.get(); // NEW: Get specific user by ID const specificUser = await User.get('user_123456'); ``` **Use Cases:** * Fetch data for specific users in admin tools * Access user information in webhooks/jobs * Multi-user data operations * User management features This enhancement allows tools, webhooks, and jobs to access any user's data, enabling more sophisticated multi-user scenarios. *** ## v3.0.2 **Released:** October 30, 2025 ### 🚀 Major Improvements to Compilation System This release brings comprehensive dependency bundling, debug mode, enhanced validation, and critical bug fixes. All components now properly bundle external dependencies: * ✅ **LuaWebhooks** bundle dependencies (e.g., Stripe, axios) * ✅ **LuaJobs** bundle dependencies * ✅ **PreProcessors** bundle dependencies (e.g., lodash) * ✅ **PostProcessors** bundle dependencies (e.g., date-fns) * ✅ **Nested Jobs** (`Jobs.create()`) independently bundle their own dependencies **Impact:** All compiled components are now truly portable and self-contained, requiring no dependency installation on deployment targets. Added `--debug` flag to `lua compile` command: `bash lua compile --debug # or LUA_DEBUG=true lua compile ` **Features:** - Verbose step-by-step logging * Shows detected imports and dependencies - Displays bundle sizes (uncompressed and compressed) - Preserves temp files for inspection - Shows timing information for each component - Full error stack traces * **tsconfig.json validation** - Clear error if missing or invalid - **Empty bundle detection** - Warns about suspiciously small bundles (under 100 bytes) * **Bundle output validation** - Ensures esbuild creates valid output - **Null config handling** - Graceful compilation without lua.skill.yaml - **Safe optional chaining** - Fixed crash when agentData is null Context-aware error messages with actionable hints: - Dependency resolution failures → "Run npm install" - TypeScript syntax errors → "Check syntax in filename.ts" - Missing files → Shows expected path - Full stack traces in debug mode Enhanced `resolveImportPath()` to support: * `.ts`, `.tsx`, `.js`, `.jsx` files * Directory imports (`index.ts`, `index.tsx`, `index.js`) **Critical Fix:** Relative imports now work correctly in Jobs, Webhooks, and Processors: ```typescript theme={null} // ✅ Now works perfectly import { MyService } from "../services/MyService"; // ✅ Also works import { MyService } from "@/services/MyService"; ``` ### 🐛 Bug Fixes * Fixed null reference error when compiling without LuaAgent * Fixed crash when lua.skill.yaml is missing * Fixed compilation with empty agent name/persona * **Critical:** Fixed relative import resolution in all component types ### 🧹 Code Quality * Removed obsolete `dynamic-job-bundler.ts` * Extracted common helpers (`extractRelevantImports`, `bundleAndCompressExecuteFunction`) * Reduced bundling.ts from 1,149 to 1,036 lines (9.8% reduction) * Added 27 comprehensive tests for bundling, execution, validation, and relative imports *** ## v3.0.0 **Released:** October 2025 ### 🎉 Major Release Version 3.0.0 focuses on developer experience, deployment automation, and real-time chat capabilities. ### ✨ New Features The flagship feature: a single, intuitive way to configure your entire agent. **Before (v2.x):** ```typescript theme={null} export const skill1 = new LuaSkill({ name: 'skill1', tools: [] }); export const skill2 = new LuaSkill({ name: 'skill2', tools: [] }); export const webhook1 = new LuaWebhook({ name: 'webhook1', execute: async () => 'ok' }); ``` **After (v3.0.0):** ```typescript theme={null} export const agent = new LuaAgent({ name: 'my-assistant', persona: 'You are a helpful AI assistant...', skills: [skill1, skill2], webhooks: [webhook1], jobs: [job1], preProcessors: [processor1], postProcessors: [processor2] }); ``` **Benefits:** * Single source of truth * Clearer organization * Automatic YAML synchronization * Better IDE support Real-time chat responses with improved UX: `bash lua chat ` - ✅ Animated typing indicator while waiting - ✅ Text streams character-by-character - ✅ Sandbox and production environment selection - ✅ Uses `/chat/stream` endpoint for real-time updates New command for deploying all components without prompts: `bash # Push all with auto-versioning lua push all --force # Push and deploy to production lua push all --force --auto-deploy ` **What it does:** 1. Compiles project 2. Reads all components from `lua.skill.yaml` 3. Increments patch versions automatically 4. Pushes all components to server 5. Deploys to production (if `--auto-deploy`) **Features:** - Auto-bumps patch versions (e.g., `1.0.0` → `1.0.1`) - Perfect for CI/CD pipelines - Retry mechanism with exponential backoff Flexible authentication with multiple sources (priority order): 1. **System Keychain** (macOS Keychain, Windows Credential Vault, Linux libsecret) 2. **Environment Variable** (`LUA_API_KEY`) 3. **.env File** (`LUA_API_KEY=...`) **Usage in CI/CD:** `bash export LUA_API_KEY=your-key lua push all --force --auto-deploy ` Bidirectional synchronization ensures consistency: **On `lua init`:** * Agent name, persona → YAML + `index.ts` LuaAgent **On `lua compile`:** * LuaAgent persona → YAML No manual synchronization needed! ### 🔧 Improvements * Excluded lua-cli internals from bundles * Reduced bundle sizes by 50-70% * Fixed relative import issues * Proper sandbox globals (tools use sandbox-provided APIs) * `code` field now properly compressed and included - Execute function properly converts to strings - Excludes lua-cli imports from job execute functions - Better metadata support for passing data * Comprehensive template with 30+ example tools * Quick Start Guide for new users * TypeScript examples with best practices * CI/CD integration examples ### 🐛 Bug Fixes **Bundling:** * Fixed `Cannot find module '../services/ApiService'` in pre-bundled tools * Fixed `process.cwd is not a function` in sandbox execution * Fixed lua-cli API code being bundled into tools **Push & Deploy:** * Fixed webhooks and jobs not found during `push all` * Fixed missing `tools` array causing validation errors * Fixed deployment timing issues with retry mechanism **Chat:** * Fixed welcome message reading from `lua.skill.yaml` * Fixed streaming endpoint integration * Fixed typing indicator cleanup on errors ### 💥 Breaking Changes These changes require updates to your existing code. **Old Way:** ```typescript theme={null} export const skill1 = new LuaSkill({ name: 'skill1', tools: [] }); export const skill2 = new LuaSkill({ name: 'skill2', tools: [] }); ``` **New Way:** ```typescript theme={null} export const agent = new LuaAgent({ skills: [skill1, skill2] }); ``` **Migration:** 1. Wrap your existing skills in a `LuaAgent` 2. Add `name` and `persona` fields 3. Run `lua compile` to sync with YAML * Old: `/chat/generate/:agentId` - New: `/chat/stream/:agentId` No action needed - handled automatically by CLI. Jobs must use `metadata` for data passing: ```typescript theme={null} await Jobs.create({ metadata: { userId: input.userId }, execute: async (job) => { const userId = job.metadata.userId; } }); ``` ### 📊 Statistics | Metric | Before | After | | ----------------- | ------- | -------------- | | Bundle overhead | \~500KB | 50-70% smaller | | Compilation speed | - | 30% faster | | Template examples | 5 | 30+ | | CLI commands | - | 25+ | *** ## Upgrade Guides ### From v3.0.x to v3.1.0 ```bash theme={null} npm install lua-cli@3.1.0 ``` **Required changes:** 1. Update `JobInstance` access patterns (use `activeVersion.schedule`, `id` instead of `jobId`) 2. Update webhook execute functions to use event object 3. Update PreProcessor responses to use discriminated union 4. Remove `welcomeMessage` from LuaAgent (configure on channel/voice instead) ### From v2.x to v3.0.0 ```bash theme={null} npm install lua-cli@3.0.0 ``` **Required changes:** 1. Wrap skills in a `LuaAgent` configuration 2. Update jobs to use `metadata` for data passing 3. Run `lua compile` to sync with YAML ```typescript theme={null} // src/index.ts import { LuaAgent } from 'lua-cli'; export const agent = new LuaAgent({ name: 'my-agent', persona: 'Your agent persona...', skills: [ /* your existing skills */ ], }); ``` # Admin Dashboard Source: https://docs.heylua.ai/channels/admin-dashboard Add and manage agent channels from the admin dashboard ## Overview The admin dashboard is the visual way to connect channels to an agent — no CLI required. Channels are configured **per agent**, so you add them from inside the agent you want to connect. ```bash theme={null} lua admin # Or visit https://admin.heylua.ai ``` The dashboard supports **more channels than the CLI** — including OAuth-based channels like Instagram. If a channel isn't available in `lua channels`, connect it here. ## Add a channel Click **Agents** in the main side navigation, then select the agent you want to connect from the list (or from the Overview cards). The agent's overview opens. On the right-hand panel, find the **Channels** section. Agent overview with the Channels section Click the **+** icon on the **Channels** row to open the channel picker. Connect a channel picker Available channels include Facebook, WhatsApp, Instagram, Slack, and Email. Select a channel and follow its connection workflow. For example, Slack first asks whether to connect a **Private** or **Public** app. Connect to Slack workflow Each channel has its own steps — see the full guides below. ## Channel setup guides Each channel's full connection flow is documented on its own page: Embedded Signup via Meta Connect a Facebook Page Direct Messages (dashboard only) Private bot or public app Generated or existing inbox Embed the Lua Pop widget ## Manage connected channels Once connected, a channel appears in the agent's **Channels** section and under the **Connections** tab at the top of the agent. From there you can review its status and reconfigure or disconnect it. To monitor channel traffic, use the agent's **Activity** tab (or the org-wide **Activity** section) for logs and conversations. ## CLI alternative Prefer the terminal? The `lua channels` command manages channels interactively: ```bash theme={null} # Connect a channel interactively (WhatsApp, Facebook, Slack, Email) lua channels # List connected channels lua channels list ``` The CLI covers the credential-based channels. **Channels that rely on an OAuth flow (such as Instagram) are only available in the dashboard.** See the [Channels Command reference](/cli/channels-command) for full CLI usage. ## Next Steps Terminal-based channel management View all channel options # Channel Capabilities Source: https://docs.heylua.ai/channels/channel-capabilities What each channel supports for outbound messaging — windows, cold start, recipients, and limits ## Overview Channels differ in what they allow for **agent-initiated** (outbound) messages: some let you message anyone any time, some require a prior conversation, and some enforce platform-specific time windows. This page is the reference matrix. For the API itself, see [Channels API](/api/channels); for the concepts, see [Proactive Messaging](/channels/proactive-messaging). ## Capability matrix | Channel | Free-form outbound | Cold start | Address by | Time window | Notes | | ------------- | ----------------------------- | --------------- | -------------------------- | ---------------------- | ------------------------------------------------- | | **WhatsApp** | In-window only | Template only | `userId`, `phoneNumber` | 24h since last inbound | Outside the window, use an approved template | | **SMS** | Any time | ✅ `phoneNumber` | `userId`, `phoneNumber` | None | Carrier opt-out (STOP/HELP/START); regional rules | | **Email** | Any time | ✅ `email` | `userId`, `email` | None | Subject, HTML, cc/bcc, attachments | | **Web chat** | To known user | — | `userId` | None | Renders the full formatting component set | | **Teams** | To known user or conversation | — | `userId`, `conversationId` | None | **Warm-only** — needs an existing conversation | | **Instagram** | To known user | — | `userId` | None | **Warm-only** | | **Messenger** | To known user | — | `userId` | None | **Warm-only** | * **Cold start** — reach a recipient who has *never* messaged your agent (by raw `phoneNumber` / `email`). * **Warm-only** — you can only message a user who already has a conversation with your agent (address them by `userId`). * **`conversationId`** — addresses a shared conversation instead of a person. Everyone in it receives the message. ## WhatsApp Free-form messages are allowed only within 24 hours of the user's last inbound message (a Meta rule). Use an approved template via `Channels.whatsapp.sendTemplate` to start or re-open a conversation. * **Cold start:** only via an approved template — you cannot free-form message a number that hasn't messaged you. * **Closed-window default:** `Channels.send` queues the message and prompts the recipient to opt in; the queued text delivers once they re-engage (`queued: true`). Override with `options.whatsapp.onClosedWindow: 'fail'` to handle the fallback yourself. * **US (+1) recipients:** the opt-in prompt is a marketing-category template that Meta may not deliver to US numbers — send a utility template directly for reliable US outreach. * **Media & rich templates:** image/video/document headers and buttons are supported — see [Templates API](/api/templates#media-headers). ## SMS * **No time window** — you can send any time. * **Cold start:** ✅ via `phoneNumber`. * **Opt-out compliance:** recipients can reply `STOP` (opt out), `HELP` (info), or `START` (opt back in). Opted-out recipients are suppressed automatically — a send to a suppressed number is rejected. * **Branding & regional rules:** outbound SMS is brand-prefixed and includes the required disclosures; delivery is subject to regional allow-lists and carrier (10DLC) registration. * **Sender number:** resolved automatically — your agent's own SMS number if configured, otherwise a shared Lua number matched to the recipient's country, otherwise a shared fallback. You don't need a dedicated number to send. ## Email * **No time window.** * **Cold start:** ✅ via `email`. * **Rich content:** subject, plain-text and/or HTML body, `cc`/`bcc`, and attachments (fetched from a public URL at send time; combined cap 28 MB). * **Threading:** replies thread back to the agent's conversation via standard email reply headers. * Use [`Channels.email.send`](/api/channels#channels-email-send) for the full shape. ## Web chat * **Warm delivery** to a known user (`userId`) — delivered to the user's embedded [chat widget](/chat-widget/introduction). * Renders the full set of [response formatting components](/formatting/introduction). ## Teams, Instagram, Messenger * **Warm-only.** You can message a user only if they already have a conversation with your agent — address them by `userId`. There is no cold-start path on these channels. ### Teams group chats and channels Teams also lets you address a **conversation** rather than a person, using `to: { conversationId }`. Use it to post into a group chat or a team channel that your agent is already part of — someone must have added the bot and @mentioned it at least once, which is what makes the conversation addressable. ```typescript theme={null} await Channels.send({ channel: 'teams', to: { conversationId: '19:9495c339...@thread.v2' }, text: 'Sample LOT-4471 is missing origin and cupping score.', }); ``` The `conversationId` is the Teams conversation id, available on inbound messages as `webhookPayload.conversation.id`. A shared conversation has no single recipient, so there is nobody to record the message against — these sends return `persisted: false` and are not written to agent memory. `to: { userId }` still means the user's **direct** conversation, never a group they happen to be in. ## Recipient resolution When you pass `to: { userId }`, the channel-native address (phone number, email, page-scoped ID, etc.) is resolved from the user's conversation history with your agent. When you pass a raw `phoneNumber` or `email`, it's used directly (cold start, where the channel supports it). A recipient must be reachable on the channel you choose. Sending WhatsApp to a `userId` who has only ever used email will fail to resolve a WhatsApp address — pick a channel the user is actually on, or use [`user.send()`](/api/user#send) to reach them on their active channel. ## Next steps The send methods and their full input/output shapes The model behind agent-initiated messages # CLI Management Source: https://docs.heylua.ai/channels/cli-management Manage channels via terminal ## Overview The `lua channels` command provides terminal-based channel management for developers who prefer command-line workflows. ```bash theme={null} lua channels ``` ## Supported Operations View all connected channels Connect new platforms Channel status and configuration Quick access to dashboard ## Complete Command Reference See complete `lua channels` command reference ## Supported Channels via CLI **Can connect via CLI:** * 📱 WhatsApp Business * 💬 Facebook Messenger * 📧 Email * 💼 Slack (Private & Public) All require credentials you provide **Require admin dashboard:** * 📸 Instagram (OAuth flow) * 💻 Website Widget (code snippet) These use visual OAuth flows or don't need "connection" ## Quick Workflows ### List All Channels ```bash theme={null} $ lua channels ? What would you like to do? 📋 List channels ✅ Found 4 channel(s) 📱 WHATSAPP - +15557986280 💬 FACEBOOK - Business Page 📧 EMAIL - support@company.com 💼 SLACK - Team Workspace ``` ### Link WhatsApp ```bash theme={null} $ lua channels → Link new channel → WhatsApp → Enter credentials ✅ Connected ``` ### View Details ```bash theme={null} $ lua channels → List channels → Select channel 📄 View full configuration ``` ### Open Admin Dashboard ```bash theme={null} $ lua channels → Link on admin dashboard 🌐 Opens browser to admin ``` ## When to Use CLI * You're already in terminal * Automating channel setup * Scripting deployments * Quick status checks * You have credentials ready * OAuth flows needed (Instagram) * Visual interface preferred * Managing many channels * Team collaboration * Viewing analytics * Non-technical users ## Next Steps Visual channel management Complete CLI documentation # Email Source: https://docs.heylua.ai/channels/email Connect your agent to email for automated responses ## Overview Email integration allows your agent to automatically respond to emails. There are two modes: * **Generated inbox** - Lua creates a dedicated email address for your agent (e.g. `agent-abc@mail.heylua.ai`) * **Existing email** - Use your own email address (e.g. `support@mybusiness.com`) with forwarding Business-appropriate channel Everyone has email Preferred for business Conversation threading ## How It Works A dedicated email address is generated for your agent Customer emails the generated address directly Agent reads and understands email Agent sends reply via email Reply appears in their inbox Customer emails: [support@yourcompany.com](mailto:support@yourcompany.com) Your email provider forwards to Lua Agent reads and understands email Agent sends reply via email Reply appears in their inbox ## Connection Method 1: CLI ```bash theme={null} $ lua channels ✅ Using agent: myAgent ? What would you like to do? 🔗 Link new channel ? Select channel type: 📧 Email ? Select email channel mode: 📬 Generate new inbox ? Enter display name (shown in email header): Support Team 📡 Creating Email channel... ✅ Email channel created successfully! 📧 Display Name: Support Team 📬 Email Address: agent-abc123@mail.heylua.ai ──────────────────────────────────────────────────── Your agent's email inbox has been created. ──────────────────────────────────────────────────── Share this address with your customers or use it in your workflows: agent-abc123@mail.heylua.ai Emails sent to this address will be handled by your agent. ──────────────────────────────────────────────────── ``` No additional setup required -- your agent is ready to receive emails immediately. ```bash theme={null} $ lua channels ✅ Using agent: myAgent ? What would you like to do? 🔗 Link new channel ? Select channel type: 📧 Email ? Select email channel mode: 📧 Use existing email ? Enter display name (shown in email header): Support Team ? Enter sender email address: support@mybusiness.com 📡 Creating Email channel... ✅ Email channel created successfully! 📧 Display Name: Support Team 📧 Sender Email: support@mybusiness.com 📬 Forward To: b5469c03-082e-481d-929b-663daf66bbef@mail.heylua.ai ──────────────────────────────────────────────────── IMPORTANT: Email Forwarding Setup Required ──────────────────────────────────────────────────── 1. Log into your email provider's settings 2. Set up email forwarding or filtering 3. Forward all emails from support@mybusiness.com to: b5469c03-082e-481d-929b-663daf66bbef@mail.heylua.ai 4. Test by sending an email to support@mybusiness.com Your agent will respond automatically! ──────────────────────────────────────────────────── ``` See [Email Provider Setup](#email-provider-setup) below for forwarding instructions. ## Connection Method 2: Admin Dashboard ```bash theme={null} lua admin ``` Or visit [https://admin.heylua.ai](https://admin.heylua.ai). Click **Agents** in the main side navigation, select your agent's card, click the **+** (plus) icon to add a channel, then choose **Email**. In the **Connect to Email** dialog, enter a **Display name** (shown in the email header, e.g. "Support Team") and pick how to connect, then click **Connect**. Lua creates a new email address your customers can email directly. Generate new email Enter your business **Email address** — you'll forward mail from it to Lua. Use existing email * **Generated** — Lua shows **"Your email address is ready!"** with the new address. Copy it and share it with your customers. * **Existing** — Lua shows a **forwarding address**. Copy it and set up forwarding from your email provider (see [Email Provider Setup](#email-provider-setup) below). Your agent only receives email once forwarding is active. ## Email Provider Setup **Gmail Forwarding:** 1. Open Gmail Settings 2. "Forwarding and POP/IMAP" tab 3. "Add a forwarding address" 4. Paste Lua forwarding address 5. Check verification email 6. Click verification link 7. Return to Gmail settings 8. Enable "Forward a copy" **Outlook Forwarding:** 1. Open Outlook Settings 2. Mail → Forwarding 3. Enable forwarding 4. Enter Lua forwarding address 5. Save changes **Workspace Routing:** 1. Admin Console 2. Apps → Gmail → Routing 3. Create routing rule 4. Condition: recipient = your email 5. Action: forward to Lua address **Email Forwarders:** 1. cPanel → Email Forwarders 2. Add Forwarder 3. From: your email 4. To: Lua forwarding address 5. Add Forwarder ## Testing Email your connected address — the generated address, or your business address once forwarding is set up: ``` To: support@mybusiness.com Subject: Test Body: Is this working? ``` In the admin dashboard, open **Activity → Logs**. You'll see the inbound email and your agent's response there as it's processed. Your agent replies automatically — the response lands in the sender's inbox. ## Accessing Email Metadata in Tools For inbound emails, `Lua.request.webhook.payload` is populated with a JMAP-aligned object containing parsed message metadata. Use this to build threaded replies, deduplicate by Message-ID, read custom headers, or implement conversation threading. ```typescript theme={null} import { Lua, LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class ThreadedReplyTool implements LuaTool { name = 'send_threaded_reply'; description = 'Send a reply to an email, preserving thread'; inputSchema = z.object({ message: z.string(), }); async execute(input: z.infer) { const webhook = Lua.request.webhook; if (Lua.request.channel === 'email' && webhook) { const { messageId, inReplyTo, subject } = webhook.payload; // Build reply subject const replySubject = subject?.startsWith('Re:') ? subject : `Re: ${subject}`; // Use messageId to thread the reply return { message: input.message, threadId: inReplyTo ?? messageId, subject: replySubject, }; } return { error: 'Not an email channel' }; } } ``` For full details on the shape, headers, and AgentMail divergence, see [Webhook Payload → Email](/api/lua#email-metadata). ## Attachments and Embedded Images Inbound emails can carry files two ways, and both reach your agent as file parts alongside the message text: * **Regular attachments** (the paperclip) — always forwarded, no size restrictions. * **Embedded images** (pasted or dragged into the compose window, e.g. in Gmail) — forwarded when they are genuinely part of the message: the image must be referenced from the email's HTML body, be an image type, and be at least 1 KB (filters out 1×1 spacer pixels). Up to **10 embedded images per email** are forwarded. Email clients insert a plain-text placeholder like `[image: photo.png]` where an embedded image sits in the body. That placeholder still appears in the message text your agent sees — but the actual image now arrives as a separate image part. Don't try to parse the placeholder; use the image part. A few behaviors worth knowing: * **Remote-hosted images are not fetched.** Images referenced by URL — for example Gmail signature images, which are hosted rather than embedded — are not downloaded. Only data actually transmitted in the email is processed. * **Embedded signature logos may come through.** Some email clients (commonly corporate Outlook setups) embed signature logos directly in the message. An embedded logo over 1 KB is indistinguishable from a content image and will reach your agent as an image part. If your users' emails are signature-heavy, consider instructing your agent to disregard branding imagery. * **Mislabeled attachments are recovered.** Some clients mark real attachments as embedded content; these are detected and forwarded as normal attachments rather than dropped. ## Sending Proactive Emails Your agent can send email on its own — receipts, confirmations, reports — using the [Channels API](/api/channels). Email has no time window, so you can send any time, and you can reach a cold address (one with no prior conversation). Pass `html` to send your exact markup as-is, or `richBody` to have markdown / `:::` components rendered into the branded email template (the same rendering your agent's replies use). Use one or the other. ```typescript theme={null} import { Channels } from 'lua-cli'; await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Your receipt', html: '

Thanks for your order!

Total: $99.00

', cc: ['accounts@example.com'], attachments: [ { filename: 'receipt.pdf', contentType: 'application/pdf', url: 'https://files.example.com/receipt.pdf' } ] }); ``` See [Channels API → email.send](/api/channels#channels-email-send) for the full shape and [Proactive Messaging](/channels/proactive-messaging) for the model. ## Best Practices Email is a more formal channel than chat. If your agent's core persona is casual or emoji-heavy, that style will carry into its emails unless you account for it. Prompt-engineer any skills or tools that generate email content to enforce a more formal tone — for example, instruct them to avoid emojis, use proper greetings and sign-offs, and write in complete paragraphs. This keeps email responses professional even when the underlying persona is playful elsewhere. * Aim for \< 1 hour response * Set auto-reply for delays * Mention expected response time * Use proper paragraphs * Include signature * Format lists clearly * Proofread responses ## Next Steps Add chat to website View all options # Facebook Messenger Source: https://docs.heylua.ai/channels/facebook-messenger Connect your agent to Facebook Page messages ## Overview Facebook Messenger integration allows your agent to respond to messages sent to your Facebook Business Page. Massive Facebook user base Connect to business page Images, videos, buttons See user's public profile ## Prerequisites Meta updates Facebook and its developer tools frequently. The steps below describe what you need; for exact, up-to-date screens, see Meta's [Messenger Platform documentation](https://developers.facebook.com/docs/messenger-platform/). You need admin access to a Facebook Page. The dashboard method connects it for you via Facebook login — no developer app required. Only needed for the CLI method. Create one at [developers.facebook.com](https://developers.facebook.com). ## Connection Method 1: Admin Dashboard (Recommended) The dashboard connects Facebook via **Facebook Login (OAuth)** — you authorize Lua in a Facebook popup and pick your Page there, so there's nothing to copy or paste. Use the [CLI method](#connection-method-2-cli-advanced) if you'd rather supply a Page access token manually. ```bash theme={null} lua admin ``` Or visit [https://admin.heylua.ai](https://admin.heylua.ai). Click **Agents** in the main side navigation, then select your agent's card. Click the **+** (plus) icon to add a channel, then choose **Facebook**. Add a channel *Screenshot: The agent's add-channel (+) control and the channel options* Tick the box to accept the **Terms of Service** and **Privacy Policy**, then click **Connect**. A Facebook login popup opens. Log in, select the **Page** you want to connect, and grant the messaging permissions Facebook requests. When the popup closes, the channel is connected. This popup is Facebook's own UI and changes periodically. See Meta's [Messenger Platform documentation](https://developers.facebook.com/docs/messenger-platform/) if you get stuck. Facebook appears in your connected channels and is ready to receive messages. ## Connection Method 2: CLI (Advanced) ### CLI Setup with Page Token From your Facebook App Dashboard: 1. Go to your app 2. Messenger → Settings 3. Generate a Page Access Token 4. Copy the token See Meta's [Messenger Platform documentation](https://developers.facebook.com/docs/messenger-platform/) for the current steps. From your Facebook Page: 1. Go to page settings 2. Copy Page ID Or from URL: `facebook.com/[page-id]` ```bash theme={null} $ lua channels ✅ Using agent: myAgent ? What would you like to do? 🔗 Link new channel ? Select channel type: 💬 Facebook Messenger ? Enter Facebook page access token: **** ? Enter Facebook page ID: 705555819301071 📡 Creating Facebook channel... ✅ Facebook channel created successfully! 💬 Page Name: My Business Page 📄 Category: Local Business 🔗 Webhook: https://wa.heylua.ai/fb/webhook/xyz789 ``` 1. Go to App Dashboard 2. Messenger → Settings → Webhooks 3. Click "Add Callback URL" 4. Paste the webhook URL from the CLI output 5. Enter your verify token 6. Subscribe to `messages`, `messaging_postbacks`, and `messaging_optins` See Meta's [Messenger Platform Webhooks guide](https://developers.facebook.com/docs/messenger-platform/webhooks) for the current UI. ## Testing Open Messenger and send a message to your Page (for example, "Hi!"). Your agent replies automatically in the Messenger thread. Monitor the conversation in the admin dashboard. Admin conversation *Screenshot: The conversation in the admin dashboard* ## Features Send and receive text Images, videos, files Suggested response buttons Reusable message templates ## Best Practices * Respond quickly (within minutes) * Facebook shows "Typically responds in..." * Fast responses improve visibility * Set greeting message * Configure away message * Use quick replies * Add call-to-action buttons * Check page rating regularly * Respond to all messages * Address negative feedback ## Next Steps Connect Instagram DMs Add email support # HTTP API Source: https://docs.heylua.ai/channels/http-api Consume your Lua agent directly via HTTP requests ## Overview The HTTP API is the external consumption interface for deployed Lua AI agents. Use it when a custom integration, mobile app, backend service, or other client needs to send a user turn to an agent and receive the response. **Calling from Lua code? Do not call these endpoints directly.** Inside a tool, job, webhook, preprocessor, or postprocessor, use [`Agents.invoke`](/api/agents) instead. It runs the same full agent pipeline without a manually managed API URL or bearer token. | Where the call originates | Use | | --------------------------------------------- | ---------------------------------------------------- | | External app, service, device, or integration | `/chat/generate/:agentId` or `/chat/stream/:agentId` | | Lua runtime code inside an agent primitive | [`Agents.invoke(targetAgentId, ...)`](/api/agents) | Real-time streaming responses via SSE Single response generation ## Base URL ``` https://api.heylua.ai ``` ## Authentication All requests require authentication via Bearer token in the Authorization header. You can use your API key as the token: ```bash theme={null} Authorization: Bearer YOUR_API_KEY ``` You can find your API key in the [Admin Dashboard](https://admin.heylua.ai) under **Settings → API Keys**. A key scoped to a specific organization or agent can only reach the resources its role covers — see [API Keys](/concepts/api-keys) for legacy vs. scoped keys and how roles are granted. *** ## Endpoints ### Stream Chat Response Stream a chat response using Server-Sent Events (SSE). ``` POST /chat/stream/:agentId ``` ### Generate Chat Response Generate a complete chat response (non-streaming). ``` POST /chat/generate/:agentId ``` **PostProcessors:** Only the `/generate` endpoint supports PostProcessors. Streaming responses (`/stream`) bypass post-processing because text is sent incrementally before the full response is available. *** ## Path Parameters | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------------------ | | `agentId` | string | **Yes** | The ID of your deployed agent. | ## Query Parameters | Parameter | Type | Required | Description | | ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `channel` | string | No | The channel context for the conversation. Options: `web`, `whatsapp`, `email`, `slack`, `facebook`, `instagram`. Defaults to `undefined`. | | `identifier` | string | No | A unique identifier for the message. Can be used for tracking purposes. | *** ## Request Body The request body follows the AI SDK 5 `UserContent` format for messages. ### Required Fields | Field | Type | Description | | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------- | | `messages` | `UserContent` | Array of content parts (text, image, or file). This is a single user message that can contain multiple parts. | ### Optional Fields | Field | Type | Description | | ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `threadId` | string | Conversation thread identifier. Turns sharing a `threadId` share history; omit it and the platform uses one default thread per user, agent, and channel. Use a fresh value to start a clean conversation. | | `systemPrompt` | string | Override the agent's default persona/prompt | | `runtimeContext` | string | Additional context injected into the agent's prompt | | `clientContext` | object | Client-side context for the request. Currently supports `timezone` — an IANA timezone string (e.g. `"Africa/Nairobi"`) used as the user's local timezone for date/time-aware responses. When omitted, the agent falls back to the user's stored profile, country, or UTC. | | `options` | object | Normalized per-request model options. `options.reasoning` — `{ effort?, show? }` — overrides the agent's `modelSettings.reasoning` for this request (the request always wins, field by field). `options.verbosity` — `'low' \| 'medium' \| 'high'` — requests output verbosity (provider support varies). See [With Reasoning Options](#with-reasoning-options). | These options are for advanced use cases and typically not needed for standard integrations: | Field | Type | Description | | ----------------------- | ------- | ------------------------------------------------------------- | | `navigate` | boolean | Enable navigation responses for web widget (default: `false`) | | `skillOverride` | array | Override the agent's skills with specific sandbox versions | | `personaOverride` | string | Override the agent's persona | | `preprocessorOverride` | array | Override preprocessors | | `postprocessorOverride` | array | Override postprocessors | *** ## Message Content Types Messages follow the AI SDK 5 `UserContent` format: ### Text Message ```json theme={null} { "type": "text", "text": "Hello, how can you help me today?" } ``` ### Image Message ```json theme={null} { "type": "image", "image": "https://example.com/image.jpg", "mediaType": "image/jpeg" } ``` ### File Message ```json theme={null} { "type": "file", "data": "https://example.com/document.pdf", "mediaType": "application/pdf" } ``` *** ## Examples ### Basic Text Request ```bash cURL theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "What products do you have available?" } ] }' ``` ```typescript TypeScript theme={null} const response = await fetch('https://api.heylua.ai/chat/generate/my-agent', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [ { type: 'text', text: 'What products do you have available?' } ] }) }); const result = await response.json(); console.log(result); ``` ```python Python theme={null} import requests response = requests.post( 'https://api.heylua.ai/chat/generate/my-agent', headers={ 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, json={ 'messages': [ { 'type': 'text', 'text': 'What products do you have available?' } ] } ) print(response.json()) ``` ### Streaming Request ```bash cURL theme={null} curl -X POST "https://api.heylua.ai/chat/stream/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "Tell me about your services" } ] }' ``` ```typescript TypeScript theme={null} const response = await fetch('https://api.heylua.ai/chat/stream/my-agent', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [ { type: 'text', text: 'Tell me about your services' } ] }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n\n').filter(line => line.trim()); for (const line of lines) { const data = JSON.parse(line); console.log(data); } } ``` ```python Python theme={null} import requests response = requests.post( 'https://api.heylua.ai/chat/stream/my-agent', headers={ 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, json={ 'messages': [ { 'type': 'text', 'text': 'Tell me about your services' } ] }, stream=True ) for line in response.iter_lines(): if line: print(line.decode('utf-8')) ``` ### With Image Attachment ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "What can you tell me about this product?" }, { "type": "image", "image": "https://example.com/product-photo.jpg", "mediaType": "image/jpeg" } ] }' ``` ### With System Prompt Override Use `systemPrompt` to temporarily override the agent's persona for a specific request: ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "Help me with my order" } ], "systemPrompt": "You are a friendly customer support agent. Be concise and helpful." }' ``` ### With Runtime Context Use `runtimeContext` to inject additional context into the agent's prompt: ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "What are my recent orders?" } ], "runtimeContext": "Current user: John Doe (ID: 12345). VIP customer since 2020." }' ``` ### With Timezone Use `clientContext.timezone` to tell the agent the user's local IANA timezone for date/time-aware responses. When omitted, the agent falls back to the user's stored profile, country, or UTC: ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "What time should I schedule my call for tomorrow morning?" } ], "clientContext": { "timezone": "Africa/Nairobi" } }' ``` ### With Reasoning Options Use `options.reasoning` to control how much the model reasons for this specific request. It works on both `/chat/generate` and `/chat/stream`, and overrides the agent's `modelSettings.reasoning` default — the request always wins, field by field (setting only `effort` doesn't clear an agent-level `show: false`): ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "Walk me through the tradeoffs of these two contract options." } ], "options": { "reasoning": { "effort": "high", "show": false } } }' ``` | Field | Type | Description | | ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `reasoning.effort` | `'off' \| 'minimal' \| 'low' \| 'medium' \| 'high' \| 'max'` | How much the model reasons before responding. Any value is safe to send for any model — Lua clamps it to the nearest behavior the resolved model supports, never erroring. Unrecognized values are ignored and defaults apply. | | `reasoning.show` | boolean | Whether the reasoning trace is surfaced in the response. Default `true`. `false` suppresses reasoning from both the stream and the generate response. | | `verbosity` | `'low' \| 'medium' \| 'high'` | Requested output verbosity (provider support varies). | Leaving `options.reasoning` unset falls back to the agent's `modelSettings.reasoning`, then to the platform default — adaptive reasoning where the model supports it, low effort otherwise. See [Model Selection → Reasoning Effort](/overview/model-selection#reasoning-effort). ### With Channel Context Specify the channel for channel-specific behavior: ```bash theme={null} curl -X POST "https://api.heylua.ai/chat/generate/my-agent?channel=whatsapp" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "type": "text", "text": "Send me the order confirmation" } ] }' ``` *** ## Response Format ### Generate Response The generate endpoint returns a complete response object: ```json theme={null} { "text": "Here are the products we have available...", "toolCalls": [], "usage": { "promptTokens": 150, "completionTokens": 200, "totalTokens": 350 } } ``` ### Stream Response The stream endpoint returns Server-Sent Events (SSE) with JSON chunks: ``` {"type":"text-delta","textDelta":"Here "} {"type":"text-delta","textDelta":"are "} {"type":"text-delta","textDelta":"the products..."} {"type":"finish","finishReason":"stop"} ``` **Reasoning visibility.** The default stream format above never carries the model's reasoning trace. Reasoning is only streamed on the AI SDK UI message stream — opt in with `?protocol=ui` on `/chat/stream`, where it arrives as `reasoning` parts. Setting `reasoning.show: false` (per request via `options.reasoning`, or per agent via `modelSettings.reasoning`) suppresses reasoning everywhere: from the UI message stream and from the generate response. *** ## Error Responses | Status Code | Description | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid request payload | | `401` | Unauthorized - Invalid, expired, suspended, or revoked token | | `403` | Forbidden - Token is valid, but its role doesn't allow this action on this resource | | `423` | Agent is disabled for this user | | `503` | Lua is restarting before your turn starts. Retry once only when the body matches the typed `CORE_DRAINING` contract below. | | `500` | Internal server error | **Standard error response format:** ```json theme={null} { "type": "error", "message": "Error description", "statusCode": 400 } ``` **Typed pre-admission rollout response:** ```json theme={null} { "error": { "code": "CORE_DRAINING", "message": "Lua is restarting. Please retry your request.", "retryable": true } } ``` When you receive this exact `503` response, the turn did not start. Retry once after the `Retry-After` header. If the header is missing, wait about one second. *** ## Long-Running Turns Turns that run research-grade tools or Space delegations can legitimately take 60–120 seconds. The HTTP edge closes a connection that has produced no output for roughly 90 seconds, so a long `/chat/generate` call — or a `/chat/stream` call during a single long tool execution — can return a `502`/`504` or a terminated stream **even though the turn completes on the platform**. The agent's reply is still generated, persisted to the conversation, and visible to every later turn on the same `threadId`. Handle it like this: 1. **Prefer `/chat/stream`** for any agent that runs tools. Streamed events extend the window and give you partial progress to show. 2. **Treat an edge cut on a long turn as "likely completed", not failed.** Do not automatically resend the same message. The original turn usually finished, and a resend runs the whole tool chain a second time. 3. **Continue on the same `threadId`.** The completed reply is already in the conversation history; a follow-up message (for example, "show me the result again") returns it without redoing the work. Do not confuse these edge cuts with the typed `503 CORE_DRAINING` response above. The `CORE_DRAINING` response is the safe retry signal. Raw `502`, `504`, and terminated transport failures are not. ### Safe retry during rollouts Retry only this case: 1. The status is `503`. 2. The body matches the typed `CORE_DRAINING` response. 3. The response arrives before any stream body or generated output. Do not retry these cases automatically: 1. Raw `502` or `504`. 2. A terminated or reset stream. 3. Any response that already streamed body data. ```typescript theme={null} async function sendTurnOnce(run: () => Promise): Promise { const first = await run(); if (!(await isCoreDraining(first))) { return first; } const delayMs = retryAfterMs(first.headers.get("retry-after")); await first.body?.cancel(); await new Promise((resolve) => setTimeout(resolve, delayMs)); return run(); } async function isCoreDraining(response: Response): Promise { if (response.status !== 503) return false; try { const payload = await response.clone().json(); return ( payload?.error?.code === "CORE_DRAINING" && payload?.error?.message === "Lua is restarting. Please retry your request." && payload?.error?.retryable === true ); } catch { return false; } } function retryAfterMs(value: string | null): number { if (value === null) return 1000; const seconds = /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN; const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now(); return Number.isFinite(delay) ? Math.min(Math.max(0, delay), 5000) : 1000; } ``` Pass a function that creates a fresh request body for each attempt. Do not reuse a consumed `Request` or `ReadableStream` body. *** ## Navigate Option The `navigate` option is specifically for web widget integrations. When enabled, it allows the agent to send navigation commands that direct users to specific pages on your website. ```json theme={null} { "messages": [{ "type": "text", "text": "Show me pricing" }], "navigate": true } ``` When `navigate` is `true`, the agent can include navigation components in its response that trigger the `onNavigate` callback in the LuaPop widget. See the Navigate Component documentation for details on how navigation works with the web widget. *** ## Best Practices For user-facing applications, use the `/chat/stream` endpoint to provide real-time feedback as the response is generated. When building integrations, specify the `channel` parameter to help the agent format responses appropriately for the platform. The `runtimeContext` field is great for injecting user-specific information or session context without modifying the agent's core persona. When using the stream endpoint, ensure you properly handle the SSE format and parse each JSON chunk separately. Research- or delegation-heavy turns can run past the edge's \~90-second no-output window and surface as a `502`/`504` while still completing on the platform. See [Long-Running Turns](#long-running-turns) before adding retry logic. *** ## Use Cases Build native mobile experiences with your Lua agent Integrate with internal tools and systems Power voice interfaces with AI responses Trigger agent responses from workflows *** ## Related Pre-built channel integrations Embeddable web widget Web navigation feature Agent configuration # Instagram Source: https://docs.heylua.ai/channels/instagram Connect your agent to Instagram Direct Messages ## Overview Instagram integration allows your agent to respond to Direct Messages sent to your Instagram Business account. Image and video-focused Popular with Gen Z and Millennials Respond to messages and story interactions Professional presence ## Prerequisites Meta updates Instagram and its developer tools frequently. The steps below describe what you need; for exact, up-to-date screens, see Meta's [Instagram Platform documentation](https://developers.facebook.com/docs/instagram-platform). Convert your Instagram account to a Professional (Business or Creator) account. Connect your Instagram account to a Facebook Business Page. Allow message access so your agent can receive DMs (in Instagram: **Settings → Messages and story replies → Connected tools → Allow access to messages**). ## Connection Method: Admin Dashboard Only **Instagram connection is only available through the admin dashboard** due to OAuth requirements and Meta's authentication flow. ```bash theme={null} lua admin ``` Or visit [https://admin.heylua.ai](https://admin.heylua.ai). Click **Agents** in the main side navigation, then select your agent's card. Click the **+** (plus) icon to add a channel, then choose **Instagram**. Add a channel *Screenshot: The agent's add-channel (+) control and the channel options* Tick the box to accept the **Terms of Service** and **Privacy Policy**, then click **Connect**. A login popup opens. Log in with the account that manages your Instagram Professional account, grant the messaging permissions requested, and select the Instagram account to connect. When the popup closes, the channel is connected. This popup is Meta's own UI and changes periodically. See Meta's [Instagram Platform documentation](https://developers.facebook.com/docs/instagram-platform) if you get stuck. Instagram appears in your connected channels and is ready to receive DMs. ## Testing Your Instagram Channel From another Instagram account, send a DM to your business account (for example, "Hi!"). Your agent replies automatically in the Instagram DM thread. Monitor the conversation in the admin dashboard. Admin conversation *Screenshot: The conversation in the admin dashboard* ## Features Respond to DMs automatically Reply to story mentions Send images, videos Suggested response buttons ## Best Practices Instagram is visual - use: * Product images * Demo videos * Infographics * Visual guides Instagram users expect: * Friendly, casual communication * Emojis and personality * Quick, concise responses * Less formal than email * Respond within minutes if possible * Instagram shows response time publicly * Fast responses improve visibility ```bash theme={null} lua admin # Check Instagram engagement metrics # Track response rates # Monitor user satisfaction ``` ## Troubleshooting **Check:** * Instagram account is Business type * Linked to Facebook Page * Permissions granted * Pop-up blocker disabled **Check:** * Channel status in admin (CONNECTED) * Instagram messaging is enabled * Account not restricted * Test with simple message **Ensure:** * Account is converted to Business * Linked to Facebook Page * Page has admin access * Try disconnecting and reconnecting ## Next Steps Connect team workspace Manage all channels visually # Channels Overview Source: https://docs.heylua.ai/channels/introduction Connect your agent to WhatsApp, Facebook, Email, Slack, and more ## What are Channels? **Channels** are the communication platforms where users interact with your AI agent. They're the "front doors" that connect your users to your agent's capabilities. ## Currently Supported Channels (7+) Professional communication Website chat widget Page messages Direct messages Business messaging Workspace integration Business communication Direct programmatic access **🚀 More channels coming soon!** SMS, Discord, Telegram, LinkedIn, and WeChat are in active development. Want a specific channel? Let us know at [support@heylua.ai](mailto:support@heylua.ai) **Channels are two-way.** Your agent doesn't only respond to inbound messages — it can **initiate** them. Send notifications, reminders, and follow-ups from tools, jobs, and webhooks with the [Channels API](/api/channels). See [Proactive Messaging](/channels/proactive-messaging) for the model and per-channel rules. ## How Channels Work Customer messages your business on WhatsApp, Facebook, Email, etc. The channel forwards the message to your Lua agent Your agent understands the message and determines response Agent calls your tools to take actions (search products, check orders, etc.) Agent's response is sent back through the same channel Customer gets response on their preferred platform ## Multi-Channel Benefits Users on WhatsApp, Facebook, Email - all talk to same agent Same capabilities and personality across all platforms Manage all channels from one place Track conversations across all channels together ## Available Channels ### WhatsApp Business * Business messaging at scale * Rich media support * High engagement rates * Popular in many regions ### Facebook Messenger * Facebook Page messaging * Reach Facebook users * Integrated with social * Rich content support ### Instagram * Direct messages * Story replies * Visual platform * Young demographic ### Email * Professional communication * B2B friendly * Universal platform * Formal inquiries ### Slack * Team collaboration * Internal use * Developer friendly * Real-time messaging ### Microsoft Teams * Business communication * Built for Microsoft 365 orgs * Chats and channel @mentions * Quick start or bring your own bot ### Website Chat Widget * Embed on website * Customizable design * Voice chat support * Analytics built-in ## Two Ways to Connect **Quick terminal-based setup** ```bash theme={null} lua channels ``` **Supported:** * ✅ WhatsApp * ✅ Facebook Messenger * ✅ Email * ✅ Slack (Private & Public) **Best for:** * Developers * Terminal workflow * Automated setup * Scripting **Visual web interface** ```bash theme={null} lua admin # Or visit: https://admin.heylua.ai ``` **Supported:** * ✅ All channels * ✅ Instagram (OAuth) * ✅ SMS/Twilio * ✅ Analytics * ✅ Monitoring **Best for:** * All users * OAuth flows * Visual management * Team collaboration ## Channel Selection Guide ### For E-commerce * ✅ **WhatsApp** - High engagement * ✅ **Instagram** - Visual products * ✅ **Facebook** - Social shoppers * ✅ **Website Widget** - On-site support ### For B2B/SaaS * ✅ **Email** - Professional * ✅ **Slack** - Team integration * ✅ **Website Widget** - Product support ### For Customer Support * ✅ **Email** - Ticket system * ✅ **WhatsApp** - Quick responses * ✅ **Website Widget** - Live help * ✅ **Facebook** - Social support ### For Internal Use * ✅ **Slack** - Team collaboration * ✅ **Microsoft Teams** - Team collaboration * ✅ **Email** - Company communication ## Quick Start ```bash theme={null} lua push lua deploy ``` Pick where your users are Use CLI or admin dashboard Send message on that platform Users can now reach your agent! ## Explore Channels Set up WhatsApp messaging Connect Facebook Page Instagram DMs Email integration Workspace integration Business communication Embed on website Direct programmatic access ## Coming Soon 🚀 We're actively adding support for more channels: Text messaging Community platform Secure messaging Professional network Chinese market Want a specific channel? Let us know at [support@heylua.ai](mailto:support@heylua.ai) Terminal-based setup Visual management # Proactive Messaging Source: https://docs.heylua.ai/channels/proactive-messaging Reach out first — how agent-initiated messages work across channels ## Channels are two-way Your agent doesn't only reply — it can **initiate**. A scheduled job can send a reminder, a webhook can confirm a payment, a tool can follow up hours later. Outbound messages flow through the [Channels API](/api/channels), and every one is recorded to the recipient's conversation thread so your agent stays coherent across the whole exchange. ```typescript theme={null} import { Channels } from 'lua-cli'; await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: "Your appointment is confirmed for tomorrow at 3pm." }); ``` ## How continuity works There's no durable "wait for reply" machinery to manage. The model is simple: 1. Your agent **sends** a message (from a tool, job, webhook, or trigger). The message is recorded to the recipient's thread. 2. Hours or days later, the user **replies** on that channel. 3. The reply wakes your agent with the **same thread loaded** — it sees the message it sent and continues naturally. A user is identified by their Lua `userId`, and one user ↔ agent pair shares one conversation thread. Send on WhatsApp, get a reply on WhatsApp — the agent has the full history either way. You don't manage sessions or state machines for this; the thread is the memory. ## Three ways to send Pick the one that matches what you have and where you want the message to go. Choose exactly which channel and recipient. Works from any context, can reach **cold** recipients (a phone number or email with no prior conversation), and records to the thread. This is the general-purpose proactive send. If you've already loaded a [`User`](/api/user) instance, `user.send([...])` delivers to that user's active conversation. Simplest when you just want to message the user you're working with, on the channel they're already on. Send an approved WhatsApp template to one or many phone numbers by channel ID. Best for campaigns and batch notifications. See the [Templates API](/api/templates). ### Which should I use? | You want to… | Use | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Message the current user, on the channel they're on | [`user.send([...])`](/api/user#send) | | Choose a specific channel (e.g. always WhatsApp) for a known user | [`Channels.send`](/api/channels#channels-send) with `to.userId` | | Reach a phone/email with no prior conversation | [`Channels.send`](/api/channels#channels-send) with `to.phoneNumber` / `to.email` | | Start or re-open a WhatsApp conversation (window closed) | [`Channels.whatsapp.sendTemplate`](/api/channels#channels-whatsapp-sendtemplate) | | Blast an approved template to many numbers | [`Templates.whatsapp.send`](/api/templates#send) | | React to a specific WhatsApp message with an emoji | [`Channels.whatsapp.sendReaction`](/api/channels#channels-whatsapp-sendreaction) | ## The WhatsApp 24-hour window WhatsApp only allows **free-form** business messages within **24 hours** of the user's last inbound message. This is a Meta rule, not a Lua one — and it shapes how proactive WhatsApp sends behave. * **Window open** (user messaged within 24h) → `Channels.send({ channel: 'whatsapp', ... })` delivers immediately (`delivered: true`). * **Window closed** → free-form isn't allowed. Two options: * **Send an approved template** with [`Channels.whatsapp.sendTemplate`](/api/channels#channels-whatsapp-sendtemplate). Templates are allowed any time and are how you start or re-open a conversation. * **Let `Channels.send` queue it** (the default). The message is held, the recipient is shown a short opt-in prompt, and the queued text is delivered once they re-engage. The call returns `queued: true` (not yet `delivered`). ```typescript theme={null} // Default: queue if the window is closed const result = await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Following up on your request.' }); if (result.queued) { // Held until the user re-engages; not delivered yet } // Or fail fast and fall back to a template yourself try { await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Following up on your request.', options: { whatsapp: { onClosedWindow: 'fail' } } }); } catch { await Channels.whatsapp.sendTemplate({ to: { userId: 'user_123' }, templateName: 'follow_up', languageCode: 'en_US', messageContext: 'Followed up on the customer’s open request' }); } ``` You **cannot** send a free-form WhatsApp message to someone who has never messaged your agent, or whose window has closed. The honest cold-start path is an approved template. Plan template content for any outreach that might land outside the window. **US (+1) recipients:** the opt-in prompt used by the queue path is a marketing-category template, which Meta may not deliver to US numbers. For reliable US outreach outside the window, send an approved utility template directly with `Channels.whatsapp.sendTemplate`. ## Per-channel constraints Windows and policies differ by channel: | Channel | Proactive send | Constraint | | --------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------ | | **WhatsApp** | Free-form (in-window) or template | 24-hour window; templates required outside it | | **SMS** | Free-form any time | No window, but subject to carrier opt-out (STOP/HELP/START) and regional rules | | **Email** | Free-form any time | No window | | **Web chat** | Free-form to a known user | Delivered to the user's web widget | | **Teams / Instagram / Messenger** | Free-form to a known user | **Warm-only** — the user must have an existing conversation with your agent | | **Teams (group)** | Free-form to a group chat or channel | **Warm-only** — address it with `to: { conversationId }`; the bot must already be in that conversation | See [Channel Capabilities](/channels/channel-capabilities) for the full matrix, sender resolution, and compliance details. ## Scheduled outbound There's no separate "campaign" primitive — scheduled outreach is just a [job](/api/luajob) that calls `Channels.send`: ```typescript theme={null} import { LuaJob, Channels } from 'lua-cli'; const reminders = new LuaJob({ name: 'appointment-reminders', description: 'Send next-day appointment reminders every morning', schedule: { type: 'cron', expression: '0 9 * * *' // every day at 9 AM }, execute: async () => { const due = await getTomorrowsAppointments(); for (const appt of due) { await Channels.send({ channel: 'whatsapp', to: { userId: appt.userId }, text: `Reminder: your appointment is tomorrow at ${appt.time}.` }); } } }); export default reminders; ``` See the [Proactive Send recipe](/examples/proactive-send) for a complete walkthrough. ## Components in proactive messages Proactive messages support the same [response formatting components](/formatting/introduction) (the `:::` blocks) as inline replies — lists, links, images, actions — on channels that render them. The web chat widget renders the full component set; messaging channels render what their platform supports (text, media), and fall back to text otherwise. To react to a specific message rather than send new text, call [`Channels.whatsapp.sendReaction`](/api/channels#channels-whatsapp-sendreaction) directly with the target message's ID — useful from a job or webhook, outside the context of a normal reply. ## Next steps Full reference for send, sendTemplate, sendReaction, and email.send Per-channel limits and sender resolution Schedule outreach with defineJob + Channels.send Schedule recurring and one-off work # Quick Testing Channels Source: https://docs.heylua.ai/channels/quick-testing Test your agent on existing Lua channels without setup ## Overview During development, you can test your newly created agent on **existing Lua channels** without setting up your own integrations. This is perfect for quick testing and validation before deploying to your own channels. **Quick Testing Only:** These channels are for development and testing. For production, you'll want to set up your own dedicated channels. No channel setup required - start testing immediately Test on WhatsApp, Facebook, Instagram, Email, and Slack Test exactly how your agent will behave in production Link and unlink agents anytime during development ## How to Link Your Agent Each channel uses a special "link" command with your agent ID. Find your agent ID in your `lua.skill.yaml` file: ```yaml theme={null} agent: agentId: agent_abc123xyz456 # ← Your agent ID ``` *** ## WhatsApp Testing **Instant link via WhatsApp:** Replace `agentId` with your actual agent ID from `lua.skill.yaml`: **Format:** ``` https://wa.me/13023778932?text=link-me-to:agentId ``` **Example with agent ID:** ``` https://wa.me/13023778932?text=link-me-to:agent_abc123xyz456 ``` Replace YOUR\_AGENT\_ID in the URL with your actual agent ID before clicking The message will be pre-filled with your link command. Just tap Send! Your agent is now linked. Start chatting to test your agent! *** ## Facebook Messenger Testing Copy this text and replace `agentId` with yours: ``` link-me-to:agentId ``` **Example:** ``` link-me-to:agent_abc123xyz456 ``` Click to open the Lua Facebook page: Click to start conversation with Lua on Messenger Paste and send the exact message you copied in Step 1 You'll receive confirmation that your agent is linked Now chat with your agent through Facebook Messenger! *** ## Instagram Testing Copy this text and replace `agentId` with yours: ``` link-me-to:agentId ``` **Example:** ``` link-me-to:agent_abc123xyz456 ``` Click to open the Lua Instagram account: Click to message @heylua.ai on Instagram Paste and send the exact message you copied in Step 1 Your agent is linked! Test through Instagram DMs *** ## Email Testing Copy this text and replace `agentId` with yours: ``` link-me-to:agentId ``` **Example:** ``` link-me-to:agent_abc123xyz456 ``` Click to open your email client: Click to compose email (replace YOUR\_AGENT\_ID in the body) Replace YOUR\_AGENT\_ID in the email body with your actual agent ID and send Reply to the confirmation email to start testing your agent via email *** ## Slack Testing First, add the Lua app to your Slack workspace: Click to authorize Lua app in your Slack workspace Copy this text and replace `agentId` with yours: ``` link-me-to:agentId ``` **Example:** ``` link-me-to:agent_abc123xyz456 ``` In Slack, find the "Lua" app in your workspace and send it a direct message with your link command Your agent is linked! Continue chatting in the Slack DM to test *** ## Finding Your Agent ID Your agent ID is in your `lua.skill.yaml` file: ```yaml theme={null} agent: agentId: agent_abc123xyz456 # ← Copy this orgId: org_xyz789 ``` Or run this command in your project directory: ```bash theme={null} # View your agent configuration lua production # Or check the YAML file cat lua.skill.yaml | grep agentId ``` *** ## Testing Workflow Use one of the methods above to link your agent to a test channel Edit your tools, skills, or persona locally ```bash theme={null} lua push ``` Chat with your agent on the linked channel to test changes Repeat steps 2-4 until satisfied *** ## Unlinking Your Agent To stop testing on a channel, send: ``` unlink-me ``` This removes the agent link from that channel. *** ## When to Use Test Channels vs Your Own ✅ **Quick development testing** * Testing during active development * Validating changes before production * Trying new features * Debugging issues ✅ **When you don't have channels yet** * Early development phase * Proof of concept * Learning the platform ✅ **Production deployment** * Customer-facing agents * Official business communications * Branded experience ✅ **Professional testing** * User acceptance testing * Team demonstrations * Client demos ✅ **Full control** * Custom branding * Analytics integration * Compliance requirements *** ## Setting Up Your Own Channels When ready for production, set up your own channels: Connect your own WhatsApp Business account Connect your Facebook page Connect your Instagram business account Set up your custom email channel Install on your Slack workspace Add chat widget to your website *** ## Troubleshooting **Make sure:** * You're using the exact format: `link-me-to:agentId` * No spaces in the command * Agent ID is correct (check `lua.skill.yaml`) * You've deployed your agent with `lua push` **Check:** * Agent is deployed: `lua push && lua deploy` * Skills are compiled: `lua compile` * No errors in logs: `lua logs` * Agent has at least one skill with tools **Solution:** * Send `unlink-me` to unlink current agent * Send new `link-me-to:agentId` with correct ID **Make sure to:** 1. Save your code changes 2. Run `lua push` to upload 3. Run `lua deploy` if needed 4. Test may need a fresh conversation (some platforms cache) *** ## Quick Reference ### Link Commands by Channel | Channel | Link / Instructions | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **WhatsApp** | [Open WhatsApp](https://wa.me/13023778932?text=link-me-to:YOUR_AGENT_ID) → Replace YOUR\_AGENT\_ID | | **Facebook** | [Open Messenger](https://m.me/61569665392939) → Send `link-me-to:agentId` | | **Instagram** | [Open Instagram DM](https://ig.me/m/heylua.ai) → Send `link-me-to:agentId` | | **Email** | [Compose Email](mailto:chat@heylua.ai?subject=Link%20Agent\&body=link-me-to:YOUR_AGENT_ID) → Replace YOUR\_AGENT\_ID | | **Slack** | [Install App](https://slack.com/oauth/v2/authorize?client_id=8037381401492.9620618924578\&scope=channels:history,channels:read,chat:write,commands,groups:history,groups:read,im:history,im:read,im:write,mpim:history,mpim:read,users:read,app_mentions:read,users:read.email,files:read\&user_scope=) → DM `link-me-to:agentId` | ### Unlink Send `unlink-me` on any channel to disconnect your agent. *** ## All Testing Channels Click to open (replace YOUR\_AGENT\_ID in URL) Click to open, then send link command Click to open, then send link command Click to compose (replace YOUR\_AGENT\_ID) Click to install Lua app on Slack Use `lua chat` for command-line testing *** ## Next Steps Use `lua chat` for local testing first Connect your own channels for production Learn the deployment process View logs and performance # Slack Source: https://docs.heylua.ai/channels/slack Connect your agent to Slack workspaces ## Overview Slack integration allows your agent to participate in workspace conversations - perfect for internal teams or community support. Internal communication Instant messaging Popular with tech teams Private Bot or Public App ## Two integration types Slack supports two connection styles. The admin dashboard wizard walks you through either one and **generates the Slack app manifest for you**, so you don't have to configure scopes and event subscriptions by hand. **Just your workspace** * Single workspace * Authenticates with a bot token (`xoxb-...`) * Simplest option for internal team use Available in the dashboard and CLI. **A distributable app** * Installable by other workspaces * Authenticates with App ID, Client ID and Client Secret * For distributing your agent beyond your own workspace Available in the dashboard. ## Connection Method 1: Admin Dashboard (Recommended) The dashboard provides a guided wizard and **generates a ready-to-paste Slack app manifest** after you connect, so you don't configure scopes, event subscriptions, or interactivity by hand. ```bash theme={null} lua admin ``` Or visit [https://admin.heylua.ai](https://admin.heylua.ai). Click **Agents** in the main side navigation, select your agent's card, click the **+** (plus) icon to add a channel, then choose **Slack**. Add a channel *Screenshot: The agent's add-channel (+) control and the channel options* Pick the connection type: * **Private — just my workspace**: use a bot token from a Slack app you control. Simplest if you only need the agent inside your own workspace. * **Public — a distributable app**: build a Slack app other organizations can install. Not sure? Start with **Private** — you can always add a public app later. Choose Slack connection type *Screenshot: The "Connect to Slack" step with the Private and Public choices* If you don't already have one, the wizard walks you through creating it at [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**. Give it any name and pick your workspace. 1. In your app, open **OAuth & Permissions** and add the [bot token scopes](#required-bot-scopes) your agent needs. 2. Click **Install to Workspace** and approve the permissions. 3. Copy the **Bot User OAuth Token** — it starts with `xoxb-`. Treat the bot token like a password — anyone with it can act as your bot. 1. Open **Basic Information → App Credentials**. 2. Copy your **App ID**, **Client ID**, and **Client Secret** (click **Show** to reveal the secret). You don't need to set up scopes, redirect URLs, or event subscriptions yet — Lua generates a manifest for that after you connect. Paste your details into the wizard and click **Connect**. * **Private**: your bot token (`xoxb-...`). * **Public**: App ID, Client ID, and Client Secret. Enter Slack credentials *Screenshot: The credentials step (bot token, or App ID / Client ID / Client Secret)* After connecting, Lua shows a generated **app manifest**. Copy it, then in Slack open **App Manifest**, switch to the **JSON** tab, paste it in, and click **Save Changes**. This sets up the permissions and event subscriptions your agent needs. Apply Slack manifest *Screenshot: The "finish in Slack" dialog with the generated manifest* **Public apps:** use the **Add to Slack** button in the dialog to install. Don't use Slack's green **Install to Workspace** button on the Install App page — it creates a token inside Slack but never finishes the connection to Lua. DM your bot in Slack, or **@mention** it in a channel it's been invited to, and your agent will reply. ## Required bot scopes Add these **Bot Token Scopes** under **OAuth & Permissions**. The dashboard manifest includes them automatically; you only add them by hand for the private bot or CLI paths. * `chat:write` — Send messages * `channels:history` — Read public channel messages * `groups:history` — Read private channel messages * `im:history` — Read direct messages * `mpim:history` — Read group direct messages * `channels:read` — List public channels * `groups:read` — List private channels * `im:read` — List direct messages * `mpim:read` — List group direct messages * `users:read` — View user information * `users:read.email` — View user emails * `app_mentions:read` — Detect @mentions * `files:read` — Access uploaded files **Reading messages:** * `channels:history` - Read public channel messages * `groups:history` - Read private channel messages * `im:history` - Read direct messages * `mpim:history` - Read group direct messages **Sending messages:** * `chat:write` - Required to send any messages **Reading channel info:** * `channels:read` - List public channels * `groups:read` - List private channels * `im:read` - List DMs * `mpim:read` - List group DMs **User information:** * `users:read` - Get user display names * `users:read.email` - Get user email addresses **Useful for:** * Personalizing responses * User identification * Profile information **Other features:** * `app_mentions:read` - Detect when bot is @mentioned * `files:read` - Read uploaded files ## Connection Method 2: CLI (Private Bot) The CLI connects a **private** bot using a bot token. Create your Slack app at [api.slack.com/apps](https://api.slack.com/apps), add the [required scopes](#required-bot-scopes), install it to your workspace, and copy the bot token first. ```bash theme={null} $ lua channels ✅ Using agent: myAgent ? What would you like to do? 🔗 Link new channel ? Select channel type: 🔒 Slack (Private) ? Enter Slack bot token (xoxb-...): **** 📡 Creating Slack channel... ✅ Slack channel created successfully! 💼 Bot Name: My Agent Bot 🏢 Workspace: My Company 🔗 Webhook: https://wa.heylua.ai/slack/webhook ``` For the CLI path you configure the Slack app yourself. Set both **Event Subscriptions** and **Interactivity** request URLs to `https://wa.heylua.ai/slack/webhook`, and subscribe to the `app_mention`, `message.channels`, `message.groups`, and `message.im` bot events. The dashboard does this for you via the generated manifest. ## Testing DM your bot in Slack (or **@mention** it in a channel it's been invited to). Your agent replies automatically. Monitor the conversation in the admin dashboard. ## Sending Proactive Messages To message a Slack user your agent is already in conversation with, load the [`User`](/api/user) and call `user.send(...)` — it delivers on the channel they're active on: ```typescript theme={null} import { User } from 'lua-cli'; const user = await User.get(userId); await user.send([{ type: 'text', text: 'Heads up — your report is ready.' }]); ``` Slack is **not yet** part of the unified [`Channels.send`](/api/channels) outbound API (which today covers WhatsApp, SMS, email, web chat, Teams, Instagram, and Messenger). For Slack, use `user.send()` to reach a user in an existing conversation. ## Next Steps Add email support Manage all channels # Microsoft Teams Source: https://docs.heylua.ai/channels/teams Connect your agent to Microsoft Teams ## Overview Microsoft Teams integration lets your agent respond inside Teams chats and channels - a natural fit for internal tooling and organizations already standardized on Microsoft 365. Built for Microsoft 365 organizations DMs, group chats, and @mentions in team channels Use Lua's shared bot with no Azure setup Full control with your own Azure Bot registration ## Two ways to connect **Recommended for most teams** * No Azure account, Bot resource, or app registration — nothing to set up on Microsoft's side * Install Lua's ready-made Teams app and link your agent * Best for getting started quickly, or when you don't need your own app identity Available in the dashboard. **Full control** * Your own Azure Bot resource, App ID, and secret * Your own Teams app manifest and branding * For organizations that require their own Azure app registration Available in the dashboard. ## Connection Method 1: Use Lua's Teams bot (Quick Start) The fastest way in — Lua hosts the bot for you. **No Azure subscription, no Bot resource, no app registration, no client secrets to manage.** You install one ready-made app and link your agent; that's the whole job. ```bash theme={null} lua admin ``` Or visit [https://admin.heylua.ai](https://admin.heylua.ai). Click **Agents** in the main side navigation, select your agent's card, click the **+** (plus) icon to add a channel, then choose **Microsoft Teams**. Add a channel *Screenshot: The agent's add-channel (+) control and the channel options* Pick **Use Lua's Teams bot**. No Azure credentials are required for this path. The dashboard gives you a Teams app package to install (or a direct install link). In Microsoft Teams, an admin uploads the app: **Apps → Manage your apps → Upload a custom app**, then selects the package Lua provided. If your tenant restricts custom app uploads, a Teams admin may need to approve it first in the Teams admin center. Installing the app doesn't yet link it to your agent — one quick step does. The dashboard gives you a **Connect** link. Open it: it starts a chat with the Lua bot and pre-fills a one-time connect message. Just **send** it, and the bot replies *"Connected to agent…"* — your organization is now linked to this agent. **Why this step exists:** Lua's bot is shared across many organizations, so this one-time link is how Lua learns which agent *your* organization belongs to. (Running your own bot instead? Then there's no connect step — see [Bring Your Own Azure Bot](/channels/teams-byo-azure-bot).) The connect link is single-use and expires after 24 hours. If it expires before you use it, regenerate it from the dashboard. **@mention** the bot in a team channel, or send it a direct message, and your agent will reply. Done — with zero Azure setup. No subscription, no Bot resource, no secrets to rotate. ## Connection Method 2: Bring Your Own Bot (Advanced) Use this path if your organization requires its own Azure app registration, or you want the bot to appear under your own name and branding. The short version: 1. In the [Azure Portal](https://portal.azure.com), create an **Azure Bot** (**Single Tenant**), set its **Messaging endpoint** to `https://wa.heylua.ai/teams/webhook`, and enable the **Microsoft Teams** channel. 2. Copy the bot's **App ID**, create a **client secret**, and note your **Tenant ID**. 3. In the Lua dashboard, choose **Bring your own bot**, paste those three values, and click **Connect** — your bot is now linked to your agent. Download the app package Lua generates, upload it to Teams, then **@mention** or DM the bot. Every click in Azure and Teams, with each concept explained, a values reference, and troubleshooting. ## Files and images Your agent can read images, documents, audio and video that users send it in Teams. Attachments are downloaded, stored on Lua's CDN, and passed to your agent the same way as on every other channel — so the model sees the actual file contents, not a link. **Where it works:** | Scope | Pasted image | File sent with the attachment button | | ----------------------------- | ------------ | ------------------------------------ | | Direct message with the agent | Yes | Yes | | Channel / group chat | Yes | **No** | Microsoft Teams does not deliver files uploaded with the attachment button to a bot outside a direct message, and gives the bot no indication one was sent. Images pasted directly into the message box come through everywhere. If someone shares a file with your agent in a channel, your agent will explain it can't see it and ask for a direct message. The attachment button only appears once the Teams app has been updated. **If you added your agent to Teams before file support shipped, download the app package again from the dashboard and re-upload it** — Teams enables the attachment button only after the update is applied. **Limits:** * Attachments over **25 MB** are declined — your agent tells the user rather than failing silently. * Whether a given file type can actually be interpreted depends on the model your agent uses. Anything the model can't read natively is converted to text where possible; otherwise your agent says so. See [Model capabilities](/agents/models). **Sending files to a user.** When your agent replies with a document, Teams shows the user a consent prompt in a direct message; accepting saves the file to their OneDrive and renders it as a native Teams file. In channels and group chats the document is delivered as a link instead, because Teams has no file-transfer API there. ## Notes & limitations * **Reactive replies and warm follow-ups only.** Your agent replies wherever it's been messaged or @mentioned. There is no cold-start path — you can't message a Teams user who has never contacted your agent. See [Channel Capabilities](/channels/channel-capabilities) for the full outbound matrix. * **Where the bot replies.** The bot responds in the same chat or channel it was messaged or @mentioned in — a DM gets a DM reply, a channel @mention gets a channel reply. * **Tenant admin approval.** Depending on your Microsoft 365 tenant's app policies, uploading a custom Teams app (Lua's shared bot or your own manifest) may require a Teams admin to allow custom app uploads or approve the app in the Teams admin center. * **Further reading:** Microsoft's [Azure Bot Service documentation](https://learn.microsoft.com/en-us/azure/bot-service/) and [Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema). ## Sending Proactive Messages To message a Teams user your agent is already in conversation with, load the [`User`](/api/user) and call `user.send(...)`, or use [`Channels.send`](/api/channels) with `channel: 'teams'` and `to: { userId }`: ```typescript theme={null} import { Channels } from 'lua-cli'; await Channels.send({ channel: 'teams', to: { userId }, text: 'Heads up — your report is ready.', }); ``` Teams is **warm-only** — you can only reach a user who already has a conversation with your agent. See [Proactive Messaging](/channels/proactive-messaging) for the model and per-channel rules. ## Next Steps Add Slack support Manage all channels # Bring Your Own Azure Bot Source: https://docs.heylua.ai/channels/teams-byo-azure-bot Create your own Microsoft Azure Bot and connect it to your Lua agent for Microsoft Teams — every step, click by click. ## Overview This is the complete, click-by-click walkthrough for the **bring-your-own-bot** path: you create a bot in Microsoft Azure under your own app registration, point it at Lua, and connect it to your agent. Choose this path if your organization requires its own Azure app registration, or you want the bot to appear under your own name and branding. Just want the fastest route with no Azure setup? Use **[Lua's shared Teams bot](/channels/teams)** instead — no Azure account, no app registration. This page is only for teams that want to run their own bot. The whole flow is short once you see the shape: **create the bot in Azure → hand its three values to Lua → install the Teams app.** Budget about 25–35 minutes. ## Before you start Two roles are involved. It's fine if that's two different people — each step notes who does what. With permission to create a resource and register an app in your Microsoft Entra ID (formerly Azure AD) directory. Usually an IT / cloud administrator. To allow a custom app to be uploaded, or to approve it for the whole organization. Good to line up early. Access to the Lua admin dashboard for the agent you want to put in Teams. **Cost:** an Azure Bot's free pricing tier (**F0**) is enough — the Microsoft Teams channel is included at no charge. ## How the pieces fit together You're building a small chain: **Azure Bot** (identity + message router) → its **messaging endpoint** points at Lua → a **Teams app** wraps the bot so people can add it → the **Lua dashboard** ties the bot to your agent. Two of those produce values you'll copy into Lua — the bot's **App ID**, its **password (client secret)**, and your **Tenant ID**. One thing worth knowing up front: because your bot's App ID is **unique to you**, handing those three values to Lua is *all it takes* to link the bot to your agent — there's no in-chat confirmation step, and you can even do it **before the Teams app exists**. The bot's identity (from Azure) and the Teams app (the wrapper people install) are separate things. More on this in Part 2. ## Part 1 — Create the Azure Bot Done in the [Azure Portal](https://portal.azure.com). This creates the bot's identity, points it at Lua, and turns on Teams. Go to [portal.azure.com](https://portal.azure.com) and sign in with your work account. In the top search bar type **Azure Bot**, then select **Azure Bot** (publisher: Microsoft). On the product page, click **Create**. Work down the fields: * **Bot handle** — a unique name for the resource, e.g. `contoso-lua-bot`. This is an internal label; it isn't the name users see in Teams. * **Subscription** — pick your Azure subscription. * **Resource group** — choose an existing one or click **Create new** (a resource group is just a folder for related Azure resources, e.g. `rg-lua-teams`). * **Pricing tier** — click **Change plan** if needed and choose **Free (F0)**. * **Type of App** — choose **Single Tenant**. Your bot serves only your own organization — this is the correct, recommended choice. * **Creation type** — leave **Create new Microsoft App ID** selected. Azure creates the app registration (the bot's identity) for you. **What "Microsoft App ID" is:** choosing *Create new Microsoft App ID* makes Azure register an **application** in your Entra ID directory alongside the bot. That app registration is the bot's login identity, and its **Application (client) ID** is what everyone calls the **App ID** — you'll copy it into Lua shortly. Click **Review + create**, then **Create**. When deployment finishes, click **Go to resource**. In the left menu open **Settings → Configuration**. Find the **Messaging endpoint** field and paste exactly: ``` https://wa.heylua.ai/teams/webhook ``` Then click **Apply**. **The messaging endpoint** is the address Microsoft calls every time someone messages your bot. Setting it to Lua's URL is what makes messages reach your agent. It's the same for everyone — no per-organization part. Still on **Settings → Configuration**, copy the **Microsoft App ID** and keep it safe — that's one of the three values Lua needs. To get the **Tenant ID**, click the **Manage Password** link next to the App ID — this opens the bot's **app registration**. On its **Overview** page both are labelled precisely: * **Application (client) ID** = your **App ID** * **Directory (tenant) ID** = your **Tenant ID** **Another place to find the Tenant ID:** search **Microsoft Entra ID** (or "Azure Active Directory") in the top bar → **Overview** → **Tenant ID**. It's a single GUID that identifies your whole organization's directory — the same value everywhere. You should now be on the app registration (from *Manage Password*). In the left menu open **Certificates & secrets** → the **Client secrets** tab → **New client secret**. * **Description** — e.g. `Lua Teams bot`. * **Expires** — pick a duration (e.g. **24 months**). Note this date. Click **Add**. A row appears with a **Value** and a **Secret ID**. **Copy the Value now.** Copy the **Value** column immediately (not the Secret ID) — Azure hides it forever the moment you leave the page. If you miss it, delete the secret and create a new one. This Value is the **App password** Lua asks for. **Set a renewal reminder.** When the secret **expires**, the bot stops replying until you create a new one and update it in Lua. Add a calendar reminder a couple of weeks before the expiry date you chose. Go back to the **Azure Bot** resource (not the app registration). Open **Settings → Channels** and click the **Microsoft Teams** icon. * Accept the **Terms of Service**. * Leave the default **Microsoft Teams Commercial** option selected. * Click **Apply** / **Save**. Teams should now show as a connected channel with status *Running*. Azure is done. The bot exists, points at Lua, and speaks Teams — and you've collected the three values Lua needs: **App ID**, **App password** (secret Value), and **Tenant ID**. ## Part 2 — Connect the bot in the Lua dashboard This links the bot to your agent — and hands you a ready-to-install Teams app package. Sign in to the Lua admin dashboard, open **Agents**, choose the agent you want in Teams, and open its **Channels**. Click **Connect** (the **+** / add-channel control) and choose **Microsoft Teams**. When asked how you want to connect, pick **Bring your own Azure Bot**. (The other option, *Use Lua's Teams bot*, skips Azure entirely but runs under Lua's identity.) Enter the values from Part 1, then click **Connect**: * **App ID** — the Application (client) ID. * **App password** — the client secret **Value**. * **Tenant ID** — the Directory (tenant) ID. **This is where the bot links to your agent — no connect link needed.** Your Azure Bot has its **own unique App ID**, so these three values *are* the connection. The moment you click **Connect**, Lua stores an active route — **(your App ID + Tenant ID) → this agent** — and you're linked. There's no in-chat "connect" step like Lua's shared bot uses. Because the identity comes entirely from Azure, you can complete this step **before you've built or installed the Teams app** — the bot's identity and the Teams app (the wrapper people add) are separate. The route is live immediately, but nothing reaches the bot until its messaging endpoint points at Lua and the Teams app is installed (Parts 3–4). After connecting, Lua shows a short setup panel that: * Lets you **download the Teams app package** — a `.zip` with your bot's App ID already baked in. This is the easy route in Part 3. * Reminds you the Azure **messaging endpoint** must be `https://wa.heylua.ai/teams/webhook`, which you already set. Keep this `.zip` handy — you'll upload it to Teams next. ## Part 3 — The Teams app package The bot needs a Teams "app" wrapper so people can add it. There are two ways to get one — pick either. **Recommended · easiest.** In Part 2 you downloaded a ready-made `.zip` with your App ID already inside. Nothing more to build — skip straight to Part 4 and install it. **Alternative.** Choose this if you want your own name, icons, and description, or to publish it to your organization's app catalog. Steps below. **Bot vs. Teams app:** the **Azure Bot** is the engine. The **Teams app** is the wrapper Teams users actually add — it carries the name, icon, and a pointer to your bot. Both packages produce the same result: a Teams app pointing at *your* bot's App ID. ### Option B — build it in the Teams Developer Portal Go to [dev.teams.microsoft.com](https://dev.teams.microsoft.com), sign in with your Microsoft 365 account, open **Apps → New app**, give it a name, and click **Add**. The **App ID** shown here is the *Teams app* ID — a different GUID from your bot's App ID. That's expected; they're two different things. Complete the required fields: short and long **name** and **description**, **developer/company name**, **website**, and **privacy** + **terms of use** URLs. Teams requires these before it will let you publish or download. Open **App features → Bot**. Under "Identify your bot", choose **Select an existing bot** and pick the Azure Bot you created — or choose **Enter a bot ID manually** and paste your bot's **App ID** (the Microsoft App ID from Part 1). Under **scopes**, tick all three so the bot works everywhere: * **Personal** — direct messages * **Team** — @mentions in channels * **Group chat** — group conversations Save. Under **Branding / Icons**, upload a **color icon (192×192 px)** and an **outline icon (32×32 px, transparent)**. This is what your team sees in Teams. Open **Publish** and pick one: * **Publish to org** — submits the app to your Teams admin; once approved it appears in *Built for your org* for everyone to add. Best for a real rollout. * **Download app package** — gives you a `.zip` to install manually (same as the Lua-generated one). Best for a quick test. ## Part 4 — Install and test in Teams Many tenants block custom app uploads by default. If the upload option is greyed out, a Teams admin enables it in the **Teams admin center → Teams apps → Setup policies → Upload custom apps**. If you published to org in Part 3 instead, the admin approves it under **Teams apps → Manage apps**. In Microsoft Teams, open **Apps** (left rail) → **Manage your apps → Upload an app → Upload a custom app**, and select the `.zip` from Part 2 or 3. Add it to **yourself** (a personal chat with the bot), to a **team/channel**, or to a **group chat** — matching the scopes you enabled. **Direct message** the bot, or **@mention** it in a channel. Your Lua agent replies — you're live. **How the bot behaves:** it replies where it's spoken to (a DM gets a DM reply; a channel @mention gets a channel reply). It responds when messaged and can follow up in an existing conversation, but it can't cold-message someone who has never contacted it. See [Proactive Messaging](/channels/proactive-messaging) for the details. ## Reference Everything you copy or set, in one place. ``` https://wa.heylua.ai/teams/webhook ``` | What Lua asks for | Where it comes from (Azure) | Its exact label | | ----------------- | ---------------------------------------------------- | ----------------------- | | **App ID** | App registration → Overview | Application (client) ID | | **App password** | App registration → Certificates & secrets | Client secret → *Value* | | **Tenant ID** | App registration → Overview (or Entra ID → Overview) | Directory (tenant) ID | | Setting | Value | | ------------------------------ | ------------------------------------ | | Azure Bot — Type of App | Single Tenant | | Azure Bot — Messaging endpoint | `https://wa.heylua.ai/teams/webhook` | | Azure Bot — Channel | Microsoft Teams (Commercial) | | Teams app — Bot scopes | Personal · Team · Group chat | | Teams app — Icons | Color 192×192 · Outline 32×32 | ## Troubleshooting * Re-check the **messaging endpoint** is exactly `https://wa.heylua.ai/teams/webhook` (no trailing space) and that you clicked **Apply**. * Confirm the **Microsoft Teams channel** shows as *Running* on the Azure Bot. * Confirm the three values in Lua are correct — a mistyped **App password** is the most common cause. Your tenant blocks custom app uploads. A Teams admin turns it on in **Teams admin center → Teams apps → Setup policies → Upload custom apps**, or approves the app after you *Publish to org*. Your **client secret expired**. Create a new secret (Part 1, step 5), copy the new **Value**, and update the **App password** in the Lua dashboard. Occasionally a freshly created bot needs a minute for its identity to propagate across Microsoft. Give it a few minutes and try again. ## Next steps The shared-bot quick start and the big picture for Teams. How your agent follows up in existing conversations. # Website Chat Widget Source: https://docs.heylua.ai/channels/website-widget Embed the Lua Pop chat widget on your website ## Overview Lua Pop is an embeddable chat widget that brings your AI agent directly to your website - no channel connection needed, just add a script tag! Single script tag Match your brand Optional voice support Works everywhere ## Two ways to set it up * **No code** — configure the widget visually in the admin dashboard, then drop in a one-line `window.LuaPop.init()` snippet. * **Inline** — pass your options directly to `window.LuaPop.init({ ... })` in code. **Configuration priority:** dashboard settings apply only when you call `window.LuaPop.init()` with **no arguments**. If your embed script passes options to `window.LuaPop.init({ ... })`, those values take priority over the settings configured in the dashboard. ## Configure in the dashboard (no code) In the admin dashboard, click **Agents** in the side navigation, select your agent's card, click the **settings cog** in the top right, then choose **Chat widget**. Open Chat widget settings from the agent settings menu Adjust the settings in the **Chat widget customization** panel on the right. Chat widget customization panel The panel includes: * **Allowed websites** *(required)* — the domains the widget is allowed to load on. * **Excluded paths** — pages where the widget should stay hidden (e.g. `/checkout`). * **General** — display mode (**floating** or **embedded**), custom instructions, chat input placeholder, and welcome message. * **Floating button settings** *(floating mode)* — position, **draggable button**, button text, color and icon, button spacing, and chat window size. * **Embedded settings** *(embedded mode)* — target container ID and conversation starters. * **Features** — voice mode, microphone, attachments, and link previews. * **Chat header** — chat title and header colors. Click **Create channel** (or **Update configuration**) to save. Under **Installation**, copy the snippet and place it before the closing `` tag on your site: ```html theme={null} ``` Because `init()` is called with no arguments, the widget uses the settings you configured in the dashboard. ## Configure inline (code) Prefer to keep configuration in code? Pass your options directly to `init()`. These override any dashboard settings. ```html theme={null} ``` Get your agent ID from your `lua.skill.yaml` file. Place the script before the closing `` tag. ## Complete Documentation Complete chat widget documentation with configuration, styling, events, frameworks, and more ## Framework Integration ```tsx theme={null} import { useEffect } from 'react'; export default function ChatWidget() { useEffect(() => { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: process.env.REACT_APP_AGENT_ID, position: "bottom-right" }); }; document.body.appendChild(script); }, []); return null; } ``` ```tsx theme={null} import Script from 'next/script'; export default function Layout({ children }) { return ( <> {children} ``` ## E-commerce Shopping Assistant ```html theme={null} ``` ## Hotel Booking Assistant ```html theme={null} ``` ## SaaS Onboarding Assistant ```javascript theme={null} // Check if user is new const isNewUser = !localStorage.getItem('user_onboarded'); window.LuaPop.init({ agentId: "onboarding-assistant", chatTitle: "Onboarding Assistant", buttonText: isNewUser ? "🚀 Get Started" : "💡 Need Help?", buttonColor: "#9b59b6", position: "bottom-right", welcomeMessage: isNewUser ? "Welcome aboard! 🚀 I'm here to help you get started. What would you like to set up first?" : "Hey! 👋 Need help with anything?", voiceModeEnabled: true, sessionId: `user-${Date.now()}`, onNavigate: (pathname, options) => { const section = pathname.replace('/', ''); document.getElementById(section)?.scrollIntoView({ behavior: 'smooth' }); } }); // Auto-open for new users if (isNewUser) { setTimeout(() => { document.querySelector('.lua-pop-button')?.click(); localStorage.setItem('user_onboarded', 'true'); }, 2000); } ``` ## Embedded Documentation Chat ```html theme={null}

Documentation

API reference and guides...

``` ## Conditional Loading ```javascript theme={null} class ConditionalChatLoader { constructor() { this.shouldLoad = this.checkConditions(); if (this.shouldLoad) { this.loadChat(); } } checkConditions() { // Only load on support pages if (window.location.pathname.includes('/support')) return true; // Only load for logged-in users if (document.cookie.includes('user_logged_in=true')) return true; // Only load during business hours const hour = new Date().getHours(); if (hour >= 9 && hour <= 17) return true; return false; } loadChat() { window.LuaPop.init({ agentId: "conditional-agent", position: "bottom-right" }); } } new ConditionalChatLoader(); ``` ## Multi-Agent Setup ```javascript theme={null} // Different agents for different pages const agentMap = { '/': 'general-support', '/pricing': 'sales-agent', '/docs': 'technical-support', '/support': 'customer-service' }; function getAgentForPage() { const path = window.location.pathname; return agentMap[path] || agentMap['/']; } window.LuaPop.init({ agentId: getAgentForPage(), position: "bottom-right", runtimeContext: `page:${window.location.pathname}` }); ``` ## Next Steps All configuration options Migrate from other chat widgets # Framework Integration Source: https://docs.heylua.ai/chat-widget/frameworks Integrate LuaPop with React, Vue, Angular, and more ## React ### Basic Integration ```tsx theme={null} import { useEffect } from 'react'; export default function ChatWidget() { useEffect(() => { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: "your-agent-id", position: "bottom-right" }); }; document.body.appendChild(script); return () => { document.body.removeChild(script); }; }, []); return null; } ``` ### With Environment Variables ```tsx theme={null} // components/ChatWidget.tsx import { useEffect } from 'react'; export default function ChatWidget() { useEffect(() => { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: process.env.REACT_APP_AGENT_ID, environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging', position: "bottom-right", sessionId: `user-${Date.now()}` }); }; document.body.appendChild(script); }, []); return null; } ``` ## Next.js ### App Router ```tsx theme={null} // app/layout.tsx import Script from 'next/script'; export default function RootLayout({ children }) { return ( {children} ``` ### Nuxt.js ```vue theme={null} export default defineNuxtPlugin(() => { if (process.client) { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: useRuntimeConfig().public.agentId, position: "bottom-right" }); }; document.body.appendChild(script); } }); ``` ## Angular ```typescript theme={null} // app.component.ts import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-root', template: ` ` }) export class AppComponent implements OnInit { ngOnInit() { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { (window as any).LuaPop?.init({ agentId: environment.agentId, position: "bottom-right" }); }; document.body.appendChild(script); } } ``` ## Svelte ```svelte theme={null} ``` ## WordPress ### Using Theme Editor ```php theme={null} ``` ### Using Plugin (Recommended) Install plugin like "Insert Headers and Footers" or "Code Snippets" and add the LuaPop initialization code to the footer. ## Shopify ```liquid theme={null} ``` ## TypeScript Support ```typescript theme={null} declare global { interface Window { LuaPop: { init: (config: LuaPopConfig) => { destroy: () => void }; config?: LuaPopConfig; }; } } interface LuaPopConfig { agentId: string; position?: "bottom-right" | "bottom-left" | "top-right" | "top-left"; buttonText?: string; buttonColor?: string; environment?: "staging" | "production" | "custom"; sessionId?: string; authToken?: string; displayMode?: "floating" | "embedded"; draggable?: boolean; voiceModeEnabled?: boolean; welcomeMessage?: string; chatInputPlaceholder?: string; chatTitle?: string; onNavigate?: (pathname: string, options: { query: Record }) => void; // ... other options } // Usage with type safety window.LuaPop.init({ agentId: "your-agent-id", position: "bottom-right" // TypeScript autocomplete works! }); ``` ## Best Practices ```tsx theme={null} // ✅ Good agentId: process.env.REACT_APP_AGENT_ID // ❌ Bad agentId: "hardcoded-id" ``` ```tsx theme={null} useEffect(() => { // Load script const script = document.createElement('script'); document.body.appendChild(script); // Cleanup return () => { document.body.removeChild(script); }; }, []); ``` ```tsx theme={null} // Next.js - only run on client useEffect(() => { if (typeof window !== 'undefined') { // Load LuaPop } }, []); ``` ## Next Steps Complete configuration options See framework-specific examples # Installation Source: https://docs.heylua.ai/chat-widget/installation Install LuaPop on your website or application ## Configuration Approaches You can configure the widget in two ways — choose what works best for your workflow: Configure appearance and behavior in the Lua AI admin dashboard. Call `window.LuaPop.init()` with no arguments — the widget pulls settings automatically. Pass configuration directly to `window.LuaPop.init({...})`. Useful for dynamic values or overriding specific dashboard settings. **Configuration priority:** Inline config passed to `window.LuaPop.init({...})` always takes priority over dashboard settings. To rely entirely on dashboard settings, call `window.LuaPop.init()` with no arguments. ## Site Whitelisting **You must whitelist your domain before the widget will load.** The widget silently refuses to initialise on any domain not in the allowed list. Go to the **Chat Widget** section in your [Lua AI admin dashboard](https://admin.heylua.ai/). Under **Customization**, add each domain where you want the widget to appear (e.g. `https://yoursite.com`). Save your settings. The widget will only initialise on whitelisted domains — requests from unlisted origins are blocked. **Testing on localhost?** `localhost` cannot be whitelisted. You must pass your config inline and set `environment: "production"` explicitly — this bypasses the domain whitelist check so the widget loads during local development: ```html theme={null} ``` ## Installation Methods Quickest setup — no build process For modern frameworks and build tools ## CDN Installation (Recommended) Perfect for static websites, WordPress, Shopify, or any HTML page. ### Step 1: Add Script Tag Add this code right before the closing `` tag: ```html theme={null} ``` ### Step 2: Initialize **Using dashboard settings (recommended):** ```html theme={null} ``` **Using inline config (advanced / overrides):** ```html theme={null} ``` ### Complete Example ```html theme={null} My Website

Welcome to My Website

Your content here...

``` ## NPM Installation For React, Vue, Angular, or other modern frameworks. ### Step 1: Install Package ```bash npm theme={null} npm install @lua/pop ``` ```bash yarn theme={null} yarn add @lua/pop ``` ```bash pnpm theme={null} pnpm add @lua/pop ``` ### Step 2: Import and Use ```tsx theme={null} import { LuaPopWidget } from '@lua/pop'; import '@lua/pop/dist/style.css'; function App() { return (
{/* Your app content */}
); } ```
```vue theme={null} ``` ```typescript theme={null} import { LuaPopWidget, LuaPopConfig } from '@lua/pop'; import '@lua/pop/dist/style.css'; const config: LuaPopConfig = { agentId: "your-agent-id", position: "bottom-right", buttonText: "Chat with AI" }; // Use in your framework ```
## Platform-Specific Installation ### WordPress Go to **Appearance** → **Theme File Editor** Find your theme's `footer.php` file Add before ``: ```html theme={null} ``` **Alternative:** Use plugin like "Insert Headers and Footers" to add the script without editing theme files. ### Shopify Go to **Online Store** → **Themes** → **Edit code** Find `theme.liquid` in **Layout** folder Add before ``: ```html theme={null} ``` Click **Save** and view your store ### Wix Go to **Settings** → **Custom Code** Click **+ Add Custom Code** → **Body - end** ```html theme={null} ``` Select "All pages" and click **Apply** ### Squarespace Go to **Settings** → **Advanced** → **Code Injection** In the **Footer** section, add: ```html theme={null} ``` Click **Save** and your chat widget will appear on all pages ### Webflow Go to **Project Settings** → **Custom Code** Paste in **Footer Code** section: ```html theme={null} ``` Publish your site to see the widget ## Single Page Applications ### React ```tsx theme={null} // components/ChatWidget.tsx import { useEffect } from 'react'; export default function ChatWidget() { useEffect(() => { // Load LuaPop script const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: process.env.NEXT_PUBLIC_AGENT_ID, position: "bottom-right", sessionId: `user-${Date.now()}` }); }; document.body.appendChild(script); // Cleanup return () => { document.body.removeChild(script); }; }, []); return null; } // In your main App component import ChatWidget from './components/ChatWidget'; function App() { return (
{/* Your app */}
); } ``` ### Next.js ```tsx theme={null} // app/layout.tsx or pages/_app.tsx import Script from 'next/script'; export default function RootLayout({ children }) { return ( {children} {/* LuaPop Widget */} ``` ## Display Modes **Perfect for most websites** Appears as a floating button that opens a chat window ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", displayMode: "floating", // Default position: "bottom-right" }); ``` Great for: * General website support * E-commerce assistance * Always-available help **Integrate into your page layout** Chat appears in a specific container on your page ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", displayMode: "embedded", embeddedDisplayConfig: { targetContainerId: "chat-container" } }); ``` Great for: * Dedicated support pages * Help center integration * Custom page layouts ## Use Cases 24/7 AI-powered customer service Answer FAQs, help with orders, troubleshoot issues Boost conversions with shopping help Product recommendations, cart assistance, checkout help Help users navigate your product Feature explanations, tutorials, onboarding Make docs searchable and interactive Answer questions, find articles, guide users ## How It Works Include LuaPop script on your website Set your agent ID and customization options Users click the chat button to start conversation Your Lua AI agent provides intelligent responses AI uses your skills to take actions (search, create orders, etc.) ## Installation Options **Quickest setup - no build process needed** ```html theme={null} ``` **For React, Vue, Angular, etc.** ```bash theme={null} npm install @lua/pop ``` ```tsx theme={null} import { LuaPopWidget } from '@lua/pop'; import '@lua/pop/dist/style.css'; ``` ## Key Benefits Add AI chat to your website in under 2 minutes. No complex setup or configuration required. Unlike traditional chat widgets that require human agents, LuaPop uses AI to provide instant, intelligent responses 24/7. Match your brand with custom colors, fonts, positions, and styling. Complete control over appearance. Automatically optimized for mobile devices with responsive layouts and touch-friendly interfaces. Token-based authentication, HTTPS encryption, session isolation, and GDPR compliance built-in. Built-in event system integrates seamlessly with Google Analytics, Mixpanel, or custom analytics. ## Quick Comparison | Feature | LuaPop | Traditional Chat Widgets | | --------------------- | ---------------- | ------------------------ | | **AI Responses** | ✅ Built-in | ❌ Requires integration | | **24/7 Availability** | ✅ Always on | ❌ Human availability | | **Setup Time** | ⚡ 2 minutes | ⏰ Hours/days | | **Voice Chat** | ✅ Optional | ❌ Usually not available | | **Customization** | ✅ Extensive | 🟡 Limited | | **Mobile Optimized** | ✅ Automatic | 🟡 Varies | | **Cost** | 💰 Pay per usage | 💰💰 Monthly fees | ## Next Steps Get LuaPop running on your website in 2 minutes Match your brand with custom styling Add to React, Vue, or any framework Monitor chat interactions and analytics ## Resources 2-minute setup guide Complete configuration reference Real-world implementations Migrate from Intercom, Zendesk, etc. Track chat interactions Common issues & solutions ## Popular Implementations Shopping assistant that helps with product discovery and checkout Features: Product search, cart management, order tracking Onboarding assistant for new users Features: Feature guidance, setup help, troubleshooting Booking concierge for reservations Features: Room availability, booking creation, local recommendations Patient assistance for appointments and information Features: Appointment scheduling, prescription refills, general info ## Need Help? Chat with other builders and get help Complete guides and references Email our support team # Migration Guide Source: https://docs.heylua.ai/chat-widget/migration Migrate from Intercom, Zendesk, Drift, and other chat widgets ## Overview This guide helps you migrate from popular chat widgets to LuaPop with minimal disruption. ## From Intercom ### Before (Intercom) ```html theme={null} ``` ### After (LuaPop) ```html theme={null} ``` ### Migration Benefits * ✅ **Simpler Setup** - Single script tag vs complex initialization * ✅ **AI-Powered** - Intelligent responses vs human-only support * ✅ **24/7 Availability** - AI never sleeps * ✅ **Lower Cost** - Pay per usage vs monthly fees ## From Zendesk Chat ### Before (Zendesk) ```html theme={null} ``` ### After (LuaPop) ```html theme={null} ``` ## From Drift ### Before (Drift) ```javascript theme={null} drift.load('your-drift-id'); ``` ### After (LuaPop) ```html theme={null} ``` ## From Tawk.to ### Before (Tawk.to) ```javascript theme={null} var Tawk_API = Tawk_API || {}; (function(){ var s1=document.createElement("script"); s1.src='https://embed.tawk.to/your-tawk-id/default'; document.head.appendChild(s1); })(); ``` ### After (LuaPop) ```html theme={null} ``` ## Configuration Mapping | Feature | Intercom | Zendesk | Drift | LuaPop | | ------------------- | -------------------------- | ------------- | -------------------- | ---------------- | | **ID** | `app_id` | `key` | `drift-id` | `agentId` | | **Position** | Not configurable | `position` | Not configurable | `position` | | **Color** | `color_override` | `color.theme` | `theme.primaryColor` | `buttonColor` | | **Button Text** | Not available | Custom CSS | `teaser.text` | `buttonText` | | **Welcome Message** | `custom_launcher_selector` | Limited | `teaser.text` | `welcomeMessage` | | **User ID** | `user_id` | Custom | `userId` | `sessionId` | ## Event Migration ### From Intercom Events ```javascript Intercom theme={null} Intercom('onShow', function() { console.log('Widget opened'); }); ``` ```javascript LuaPop theme={null} window.addEventListener('message', function(event) { if (event.data?.type === 'LUA_POP_EVENT') { if (event.data.eventType === 'widget_opened') { console.log('Widget opened'); } } }); ``` ### From Zendesk Events ```javascript Zendesk theme={null} zE('webWidget:on', 'open', function() { console.log('Opened'); }); ``` ```javascript LuaPop theme={null} window.addEventListener('message', function(event) { if (event.data?.type === 'LUA_POP_EVENT') { if (event.data.eventType === 'widget_opened') { console.log('Opened'); } } }); ``` ## Gradual Migration Roll out LuaPop to a percentage of users: ```javascript theme={null} class GradualMigration { constructor() { this.migrationPercentage = 25; // Start with 25% this.shouldUseLuaPop = this.inMigrationGroup(); if (this.shouldUseLuaPop) { this.initLuaPop(); } else { this.initLegacyChat(); } } inMigrationGroup() { const userId = this.getUserId(); const hash = this.simpleHash(userId); return (hash % 100) < this.migrationPercentage; } simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); } return Math.abs(hash); } getUserId() { return localStorage.getItem('user_id') || 'anonymous'; } initLuaPop() { window.LuaPop.init({ agentId: "migration-agent", position: "bottom-right" }); } initLegacyChat() { // Your existing chat solution } } new GradualMigration(); ``` ## Migration Checklist * [ ] Audit current chat setup * [ ] Document custom features * [ ] Plan agent configuration * [ ] Test LuaPop in staging * [ ] Start with 10-25% of users * [ ] Monitor performance metrics * [ ] Collect user feedback * [ ] Compare analytics data * [ ] Remove legacy chat code * [ ] Update documentation * [ ] Train support team * [ ] Optimize configuration ## Next Steps Complete configuration options Get started with LuaPop # Quick Start Source: https://docs.heylua.ai/chat-widget/quick-start Get LuaPop running on your website in 2 minutes ## Before You Start: Whitelist Your Website **You must whitelist your domain before the widget will load.** In the [Lua AI admin dashboard](https://admin.heylua.ai/), go to **Chat Widget** → **Customization** and add your website URL to the allowed sites list. The widget silently refuses to initialise on any domain not listed here. **Testing on localhost?** `localhost` cannot be whitelisted — the dashboard approach will not work. You must pass your configuration inline and set `environment: "production"` explicitly: ```html theme={null} ``` This bypasses the domain whitelist check so you can develop and test locally against your real agent. ## Setup Options There are two ways to get the widget running. The **dashboard approach** is recommended — it requires no code changes when you update settings. Configure everything from the Lua AI admin dashboard and embed a single script with no payload. In the admin dashboard, go to **Chat Widget** → **Customization** and add your website URL(s) to the allowed sites list. The widget will only load on whitelisted domains. Set your agent behavior, appearance, and features directly in the dashboard UI — no code required. Copy and paste this code right before the closing `` tag: ```html theme={null} ``` Refresh your page — the chat button will appear using your dashboard settings. **That's it!** Update appearance or behavior any time from the dashboard — no code deploys needed. ✨ **Configuration priority:** If you pass options to `window.LuaPop.init({...})`, those values override the dashboard settings. To use dashboard settings exclusively, call `window.LuaPop.init()` with no arguments. Pass configuration directly in the script for full programmatic control, or to override specific dashboard values. **This is also required when testing on localhost.** Copy and paste this code right before the closing `` tag: ```html theme={null} ``` Get your agent ID from the [Lua AI admin dashboard](https://admin.heylua.ai/) and replace `"your-agent-id"` Refresh your page — you should see a chat button in the bottom-right corner! **That's it!** Your AI chat widget is now live. ✨ ## Common Setups ### Customer Support ```html theme={null} ``` ### Sales Assistant ```html theme={null} ``` ### Embedded Chat (No Floating Button) ```html theme={null}
``` ## Quick Customizations ### Change Position ```javascript theme={null} position: "bottom-right" ``` ```javascript theme={null} position: "bottom-left" ``` ```javascript theme={null} position: "top-right" ``` ```javascript theme={null} position: "top-left" ``` ### Custom Colors & Button ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", buttonColor: "#ff6b6b", // Custom button color buttonText: "💬 Chat Now", // Custom text buttonIcon: "🤖", // Custom icon/emoji chatTitle: "AI Assistant" // Custom title }); ``` ## Testing Environments ### Staging (For Testing) ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", environment: "staging", // Uses api.lua.dev position: "bottom-right" }); ``` ### Production (Live) ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", environment: "production", // Uses api.heylua.ai position: "bottom-right" }); ``` Always test with `environment: "staging"` before switching to production! ## Framework Integration Quick Start ### React ```tsx theme={null} import { useEffect } from 'react'; export default function ChatWidget() { useEffect(() => { const script = document.createElement('script'); script.src = 'https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js'; script.onload = () => { window.LuaPop?.init({ agentId: "your-agent-id", position: "bottom-right" }); }; document.body.appendChild(script); }, []); return null; } ``` ### Vue ```vue theme={null} ``` See integration guides for Angular, Next.js, and more ## Troubleshooting **Check these:** 1. Open browser console for JavaScript errors 2. Verify your agent ID is correct 3. Ensure script is loaded after DOM is ready 4. Check if another chat widget is conflicting **Solutions:** 1. Check your internet connection 2. Verify agent ID exists in Lua AI dashboard 3. Try `environment: "staging"` for testing 4. Check browser console for API errors **Solutions:** 1. Check for CSS conflicts using browser dev tools 2. Try a different position 3. Add higher z-index: ```javascript theme={null} popupButtonStyles: { zIndex: "9999" } ``` ## Next Steps Explore all configuration options Match your brand perfectly See production implementations Monitor chat interactions # Styling & Customization Source: https://docs.heylua.ai/chat-widget/styling Customize LuaPop to match your brand perfectly ## Custom Colors ### Button Color ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", buttonColor: "#ff6b6b" // Any hex color }); ``` ## Custom Button Styling ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", // Button text and icon buttonText: "💬 Chat Now", buttonIcon: "🤖", // Button styles popupButtonStyles: { borderRadius: "25px", width: "auto", height: "50px", padding: "0 20px", fontSize: "16px", fontWeight: "600", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", transition: "all 0.3s ease" } }); ``` ## Custom Positioning ```javascript theme={null} popupButtonPositionalContainerStyles: { bottom: "30px", right: "30px", zIndex: "9999" } ``` ### Position Presets ```javascript Bottom Right theme={null} position: "bottom-right" ``` ```javascript Bottom Left theme={null} position: "bottom-left" ``` ```javascript Top Right theme={null} position: "top-right" ``` ```javascript Top Left theme={null} position: "top-left" ``` ## Chat Header Styling ```javascript theme={null} chatTitleHeaderStyles: { background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "white", padding: "15px 20px", borderRadius: "15px 15px 0 0", fontWeight: "bold" } ``` ## Brand Integration ```javascript theme={null} chatHeaderSubtitle: { visible: true, brandName: "Your Company", iconUrl: "https://yoursite.com/logo.png", linkUrl: "https://yoursite.com" } ``` ## CSS Class Overrides ```css theme={null} /* Override button styles */ .lua-pop-button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; border: none !important; box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3) !important; } /* Override chat header */ .lua-pop-chat-header { background: #2c3e50 !important; color: white !important; } /* Custom animations */ .lua-pop-widget { animation: slideInUp 0.3s ease-out; } @keyframes slideInUp { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } ``` ## Complete Styling Example ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", // Floating button position position: "bottom-right", // Button buttonText: "💬 Chat Now", buttonColor: "#8B5CF6", // Button custom styles popupButtonStyles: { borderRadius: "30px", padding: "12px 24px", fontSize: "15px", fontWeight: "600", boxShadow: "0 8px 20px rgba(139, 92, 246, 0.3)", border: "2px solid rgba(255, 255, 255, 0.3)" }, // Positioning popupButtonPositionalContainerStyles: { bottom: "25px", right: "25px", zIndex: "9999" }, // Chat header chatTitle: "AI Assistant", chatTitleHeaderStyles: { background: "linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)", color: "white", padding: "16px 20px", borderRadius: "12px 12px 0 0", fontWeight: "700" }, // Branding chatHeaderSubtitle: { visible: true, brandName: "Your Company", iconUrl: "/logo.png", linkUrl: "/" }, // Messages welcomeMessage: "Hi there! 👋 How can I help you today?", chatInputPlaceholder: "Ask anything..." }); ``` ## Next Steps Listen to chat interactions See styling examples # Troubleshooting Source: https://docs.heylua.ai/chat-widget/troubleshooting Common issues and solutions for LuaPop ## Common Issues ### Widget Not Appearing **Check:** 1. Open browser console (F12) 2. Look for JavaScript errors 3. Verify script URL is correct **Solution:** ```html theme={null} ``` **Check:** 1. Verify agent ID from Lua AI dashboard 2. Check for typos 3. Ensure agent is active **Solution:** ```javascript theme={null} // Double-check agent ID console.log('Agent ID:', 'your-agent-id'); ``` **Check:** * Is script in `` instead of before ``? **Solution:** ```html theme={null} ``` **Check:** * Other chat widgets interfering * CSS hiding the button **Solution:** ```javascript theme={null} popupButtonStyles: { zIndex: "99999", // Higher z-index display: "block !important" } ``` ### Button Shows But Chat Won't Open **Check:** 1. Open Network tab in dev tools 2. Look for failed API requests 3. Check internet connection **Solution:** Try staging environment: ```javascript theme={null} environment: "staging" ``` **Error:** Agent ID doesn't exist on server **Solution:** 1. Verify agent exists in Lua AI dashboard 2. Check environment matches (staging vs production) 3. Try different agent ID **Check:** * Invalid auth token * Expired session **Solution:** ```javascript theme={null} // Remove auth token to test window.LuaPop.init({ agentId: "your-agent-id", // authToken: "...", // Comment out temporarily }); ``` ### Embedded Mode Issues **Error:** Target container doesn't exist **Solution:** ```html theme={null}
```
**Check:** * Container has dimensions (width/height) * Container is not hidden by CSS **Solution:** ```css theme={null} #chat-container { width: 400px; height: 600px; display: block; visibility: visible; } ```
### Styling Issues **Solution:** ```javascript theme={null} popupButtonPositionalContainerStyles: { bottom: "20px !important", right: "20px !important", position: "fixed !important" } ``` **Check:** * CSS specificity conflicts * Theme overrides **Solution:** ```javascript theme={null} // Use more specific styles popupButtonStyles: { backgroundColor: "#ff6b6b !important" } ``` **Solution:** Add `!important` or increase specificity: ```css theme={null} .lua-pop-button { background: #ff6b6b !important; } /* Or use higher specificity */ body .lua-pop-widget .lua-pop-button { background: #ff6b6b; } ``` ### Voice Chat Issues **Check:** 1. HTTPS enabled (required for microphone) 2. Browser permissions granted 3. Microphone physically working **Solution:** * Use HTTPS (voice requires secure connection) * Check browser permissions * Test microphone in other apps **Issue:** User denied microphone permission **Solution:** Widget shows fallback to text input automatically ## Debug Mode Enable detailed logging: ```javascript theme={null} // Use staging for verbose logging window.LuaPop.init({ agentId: "your-agent-id", environment: "staging" // More detailed logs }); // Check console for detailed information console.log('LuaPop config:', window.LuaPop.config); ``` ## Browser Console Checks ### Verify LuaPop Loaded ```javascript theme={null} // In browser console console.log(window.LuaPop); // Should show the LuaPop object console.log(window.LuaPop.config); // Should show your configuration ``` ### Check for Errors ```javascript theme={null} // Look for errors in console // Common errors: // - Script loading failed // - Agent not found // - Network errors // - Permission denied ``` ## Getting Help ### Diagnostic Information When reporting issues, include: ```javascript theme={null} // Browser info console.log('Browser:', navigator.userAgent); // LuaPop version console.log('LuaPop:', window.LuaPop); // Configuration console.log('Config:', window.LuaPop.config); // Current errors console.error('Errors:', /* copy error messages */); ``` ### Support Channels Get real-time help from the community Search these docs Get help from our team ## Prevention Tips ```javascript theme={null} // Always test new changes in staging const env = process.env.NODE_ENV === 'production' ? 'production' : 'staging'; window.LuaPop.init({ agentId: "your-agent-id", environment: env }); ``` ```javascript theme={null} // Check if LuaPop loaded if (window.LuaPop) { window.LuaPop.init({ /* config */ }); } else { console.error('LuaPop failed to load'); // Show fallback support option } ``` ```javascript theme={null} window.addEventListener('message', function(event) { if (event.data?.type === 'LUA_POP_ERROR') { console.error('LuaPop Error:', event.data.error); // Show fallback UI showFallbackSupport(); } }); ``` ## Next Steps Review configuration options Learn about event system # Voice Chat Source: https://docs.heylua.ai/chat-widget/voice-chat Enable voice interactions in your chat widget ## Overview LuaPop supports optional voice chat functionality, allowing users to speak with your AI agent instead of typing. ## Enable Voice Chat ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", position: "bottom-right", voiceModeEnabled: true // ✅ Enable voice chat }); ``` ## Features Users can speak their messages AI responses can be spoken aloud Supports multiple languages Great for accessibility and mobile ## How It Works User clicks microphone icon in chat input Browser asks for microphone access User speaks their message Audio converted to text automatically AI processes and responds as normal Response can be played as audio ## Use Cases ### Accessibility Voice chat improves accessibility for: * Users with motor impairments * Users who prefer speaking * Hands-free scenarios * Visually impaired users ### Mobile Users Perfect for mobile where typing is harder: ```javascript theme={null} const isMobile = window.innerWidth <= 768; window.LuaPop.init({ agentId: "mobile-agent", voiceModeEnabled: isMobile, // Enable on mobile only buttonText: isMobile ? "🎤 Voice Chat" : "💬 Chat" }); ``` ### Driving/Hands-Free Ideal for automotive or hands-free scenarios: ```javascript theme={null} window.LuaPop.init({ agentId: "hands-free-agent", voiceModeEnabled: true, chatTitle: "Voice Assistant", buttonText: "🎤 Voice Chat", welcomeMessage: "Hi! I'm your voice assistant. Tap the microphone to speak to me.", chatInputPlaceholder: "Click mic to speak..." }); ``` ## Browser Support Voice chat requires: * Modern browser with Web Speech API * HTTPS connection (required for microphone access) * User permission for microphone ### Supported Browsers | Browser | Speech-to-Text | Text-to-Speech | | ------------- | -------------- | -------------- | | Chrome | ✅ Full | ✅ Full | | Safari | ✅ Full | ✅ Full | | Firefox | ✅ Full | ✅ Full | | Edge | ✅ Full | ✅ Full | | Mobile Safari | ✅ Full | ✅ Full | | Chrome Mobile | ✅ Full | ✅ Full | ## Privacy & Permissions Voice chat requires user permission to access the microphone. Users must explicitly grant permission. ### Permission Handling The widget automatically: 1. Requests microphone permission when needed 2. Shows appropriate UI if permission denied 3. Gracefully falls back to text-only chat ### Privacy * Audio is processed in real-time * No audio recordings are stored locally * Audio converted to text server-side * GDPR compliant ## Best Practices Voice chat only works on HTTPS: ```javascript theme={null} const isSecure = window.location.protocol === 'https:'; window.LuaPop.init({ agentId: "your-agent-id", voiceModeEnabled: isSecure // Only enable on HTTPS }); ``` Let users know voice is available: ```javascript theme={null} window.LuaPop.init({ agentId: "your-agent-id", voiceModeEnabled: true, buttonText: "🎤 Voice Chat Available" }); ``` Voice chat is especially useful on mobile: ```javascript theme={null} const isMobile = window.innerWidth <= 768; window.LuaPop.init({ agentId: "your-agent-id", voiceModeEnabled: true, buttonText: isMobile ? "🎤" : "💬 Chat", chatInputPlaceholder: isMobile ? "Tap mic to speak" : "Type or click mic to speak" }); ``` ## Next Steps All configuration options See voice chat implementations # Authentication Source: https://docs.heylua.ai/cli/authentication Sign in to Lua CLI or configure an API key ## Overview Lua CLI requires authentication to access the platform and deploy skills. For interactive work, sign in with email and keep a renewable session. For automation, configure an API key through an environment variable, a local credentials file, or a project `.env` file. The CLI does not use the system keychain. New email login creates a renewable user session that follows your current organizations and agents. Login no longer asks you to choose an organization, agents, or role. Existing scoped and non-dotted legacy keys continue to work when supplied directly. Lua does not force legacy-key rotation or expiry. See [API Keys](/concepts/api-keys). ## Commands Set up authentication Display stored key Remove credentials ## lua auth configure Sign in for interactive work or configure an API key for automation. ```bash theme={null} lua auth configure ``` ### Authentication Methods **Sign in with a renewable session** 1. Choose **Email**. 2. Enter your email address and the six-digit code sent to you. 3. The CLI saves a renewable first-party session. 4. Choose an agent later with `lua init` in each project directory. ```bash theme={null} $ lua auth configure ? Choose authentication method: Email ? Enter your email address: you@example.com ? Enter the OTP code: 123456 ✅ Signed in as you@example.com. ``` The CLI reads your current organizations and agents from Lua when each command runs. Creating an agent or joining another organization does not require another CLI login. **Existing API Key** Use this method if you already have a scoped or legacy API key: 1. Choose **API Key**. 2. Enter the existing key. 3. The CLI validates and saves the key without replacing, rotating, or revoking it. ```bash theme={null} $ lua auth configure ? Choose authentication method: API Key ? Enter your API key: api_abc123def456... 🔐 Validating API key... ✅ API key validated and saved securely. ``` Use direct keys for CI, existing integrations, and installations that already have a working credential. ### Non-Interactive Authentication Direct-key setup and email OTP support non-interactive commands: ```bash theme={null} # Direct API key (one shot) lua auth configure --api-key "api_xxx..." # Email OTP: request a code lua auth configure --email you@example.com # Email OTP: verify and save a renewable session lua auth configure --email you@example.com --otp 123456 ``` | Option | Description | | ----------------- | ---------------------------------------------------------------- | | `--api-key ` | Validate and save an existing scoped or legacy key unchanged. | | `--email ` | Request an OTP to be sent to this email. Step 1 of the OTP flow. | | `--otp ` | Verify the six-digit code. Combine it with `--email`. | For CI, create a scoped key through a trusted setup flow, store it in your CI secret manager, and expose it as `LUA_API_KEY`. Do not put an email address, OTP, or credential into an AI conversation. ### Where credentials are stored Interactive sessions and API keys use separate owner-only files: ``` ~/.lua-cli/sessions/.json ~/.lua-cli/credentials ``` Email login stores renewable session data in the environment-specific session file. Direct API-key setup writes `~/.lua-cli/credentials`. On POSIX systems, both files use mode `0600`. The file contains the secret as plaintext. The "saved securely" CLI message refers to the owner-only file permissions, not encryption at rest. Never commit API keys to version control or share them publicly! ## Environment Variable Authentication For CI/CD, Docker, and headless servers, set the `LUA_API_KEY` environment variable directly — no credentials file needed: ```bash theme={null} export LUA_API_KEY=your-api-key ``` The environment variable always takes priority over a renewable session or stored API key. ```bash theme={null} export LUA_API_KEY=your-api-key lua push ``` Add to `~/.zshrc` or `~/.bashrc` for persistence. Create a `.env` file in your project root: ```bash theme={null} LUA_API_KEY=your-api-key ``` The CLI loads `.env` automatically. Add `.env` to `.gitignore`. ```dockerfile theme={null} FROM node:22-slim ENV LUA_API_KEY=your-api-key RUN npm install -g lua-cli ``` Or pass at runtime: ```bash theme={null} docker run -e LUA_API_KEY=your-api-key my-image lua push ``` ```yaml theme={null} - name: Deploy env: LUA_API_KEY: ${{ secrets.LUA_API_KEY }} run: lua push all --force ``` Add `LUA_API_KEY` to your repository secrets. ### Key Resolution Order The CLI checks sources in this priority order: | Priority | Source | Best for | | -------- | ---------------------------------------------------------------------------------------- | ------------------------------- | | 1 | `LUA_API_KEY`; if it is not exported, the CLI loads it from the current project's `.env` | CI/CD, Docker, headless servers | | 2 | Renewable session for the active Lua environment | Interactive development | | 3 | `~/.lua-cli/credentials` | Existing API-key setup | ## lua auth key Display a stored API key. A renewable session is never displayed as an API key. ```bash theme={null} lua auth key ``` ### Security Confirmation For security, you must confirm before displaying the key: ```bash theme={null} $ lua auth key ? This will display your API key. Are you sure you want to continue? Yes 🔑 Your API key: api_abc123def456... ``` Use `--force` to skip the confirmation prompt (useful in scripts): ```bash theme={null} lua auth key --force ``` If you signed in with email, the command confirms that you have a renewable session and does not print its credential. ### Use Cases * **Existing key setup**: Inspect an API key saved through direct-key configuration * **CI/CD configuration**: Copy that key into a secret manager * **Verification**: Confirm whether a local API key is configured ## lua auth logout Sign out and remove locally stored authentication. ```bash theme={null} lua auth logout ``` ### Confirmation Required ```bash theme={null} $ lua auth logout ? Sign out? A renewable login also signs you out of other Lua first-party sessions. Yes ✅ Signed out and removed local authentication. ``` Use `--force` to skip the confirmation: ```bash theme={null} lua auth logout --force ``` ### What Happens The active environment's renewable session and any locally stored API key are removed. You'll need to run `lua auth configure` or set `LUA_API_KEY` to use the CLI again If you used email login, Lua also signs out your other first-party sessions. If Lua cannot confirm that remote sign-out, the CLI warns you after removing the local session. If you used direct API-key setup, logout removes only the local copy. The key remains valid until you revoke it in the dashboard. An API key still works wherever else it is configured, such as a CI environment variable. To revoke it everywhere, use **Settings → API Keys** in the admin dashboard. See [Managing a key](/concepts/api-keys#managing-a-key). ## Troubleshooting **Error**: `No Lua CLI authentication found.` **Solution**: Authenticate using one of: ```bash theme={null} lua auth configure # Email sign-in or direct API-key setup export LUA_API_KEY=your-key # Environment variable echo "LUA_API_KEY=key" >> .env # .env file ``` **Problem**: Didn't receive OTP email **Solutions**: 1. Check spam/junk folder 2. Wait a few minutes (can take up to 5 min) 3. Try again with `lua auth configure` 4. Use API Key method instead **Error**: `❌ Invalid OTP code` **Solutions**: * Double-check the code from email * OTP expires after 10 minutes * Request new OTP by running command again **Error**: `❌ Authentication failed` A `401` means the key itself is the problem — it's invalid, expired, suspended, or revoked: * Verify you copied the complete key * Check the key hasn't expired, been suspended, or been revoked in **Settings → API Keys** * Ensure no extra spaces * Create a replacement through **Settings → API Keys** A `403` is different — the key is valid, but its role doesn't allow the action you're attempting. Grant it a broader role or an additional organization/agent in **Settings → API Keys**. See [API Keys → Errors](/concepts/api-keys#errors). Previous versions stored credentials in the OS keychain (via `keytar`). Starting in v3.9.0, credentials are stored in `~/.lua-cli/credentials`. This step does not rotate or invalidate the existing key. It copies the key into a credential source that current versions read. **One-time migration step:** ```bash theme={null} lua auth configure ``` Or set the environment variable directly: ```bash theme={null} export LUA_API_KEY=your-api-key ``` ## Best Practices Email login follows your current Lua access. Use `lua init` in each project to select the agent for that directory. Inject a scoped key from a secret manager. These environments do not need a credentials file. ```bash theme={null} # GitHub Actions env: LUA_API_KEY: ${{ secrets.LUA_API_KEY }} # Docker docker run -e LUA_API_KEY=your-key my-image lua push ``` * Don't commit `.env` to git (add to `.gitignore`) * Don't share in chat or email * Use `lua auth logout` when done on shared machines * Rotate a key if it is exposed or when your security policy requires it Create a dedicated [scoped key](/concepts/api-keys) per integration, granted only the role it needs on the organizations or agents it touches — rather than sharing one broad key everywhere. Legacy keys keep working with no expiry, but a scoped key limits what a leaked credential can do. ```bash theme={null} LUA_TELEMETRY=false lua push ``` Or set `LUA_TELEMETRY=false` in your CI environment variables. ## Next Steps Legacy vs. scoped keys, roles, and key management Create your first skill after authentication Deploy primitives to production # Channels Command Source: https://docs.heylua.ai/cli/channels-command Manage agent communication channels ## Overview The `lua channels` command provides an interactive interface for managing your agent's communication channels - connect to WhatsApp, Facebook, Email, Slack, Instagram, and more. ```bash theme={null} lua channels ``` ### Non-Interactive Mode ```bash theme={null} # List all channels lua channels list ``` | Action | Description | | ------ | --------------------------- | | `list` | List all connected channels | **Note:** Creating and configuring channels requires interactive mode due to complex multi-step input for tokens, IDs, and other credentials. View all connected channels Connect WhatsApp, Facebook, Slack, Email View configuration and status Open visual interface ## Currently Supported Channels Lua currently supports 6 communication channels: **Email Integration** Respond to emails automatically. Two modes available: **Generated Inbox Requirements:** * Display Name **Existing Email Requirements:** * Display Name * Sender Email Address * Email forwarding setup **Features:** * Automated email responses * Custom sender identity * Professional communication * Generated inbox (no forwarding needed) **CLI Support:** ✅ Yes **LuaPop Widget** Chat widget for websites. **Requirements:** * Agent ID (from lua.skill.yaml) **Features:** * Embeddable widget * Customizable styling * Voice chat support * Mobile responsive **Setup:** Code snippet (no channel connection needed) See [Chat Widget docs](/chat-widget/introduction) **Facebook Messenger** Connect to Facebook Page messages. **Requirements:** * Page Access Token * Page ID **Features:** * Respond to page messages * Customer conversations * Rich media support **CLI Support:** ✅ Yes **Instagram Messenger** Respond to Instagram DMs. **Requirements:** * Instagram Business Account * Linked to Facebook Page **Features:** * DM responses * Story replies * Visual platform **CLI Support:** ❌ Admin Dashboard only (OAuth) **WhatsApp Business** WhatsApp Business API integration. **Requirements:** * Phone Number ID * WABA ID * Access Token **Features:** * Business messaging * Rich media support * Quality ratings * Global reach **CLI Support:** ✅ Yes **Slack Integration** Connect to Slack workspaces. **Types:** * Private Bot (simple) * Public OAuth (distributable) **Features:** * Direct messages * Channel messages * Team collaboration **CLI Support:** ✅ Yes (both types) **🚀 More channels coming soon!** SMS, Microsoft Teams, Discord, Telegram, LinkedIn, and WeChat are in active development. ## Interactive Flow ```bash theme={null} lua channels ``` Automatically loads agent from `lua.skill.yaml` ``` ✅ Using agent: myAgent ? What would you like to do? 📋 List channels 🔗 Link new channel 🌐 Link on admin dashboard ``` Follow the prompts based on your selection ## List Channels View all configured channels with detailed information: ```bash theme={null} ? What would you like to do? 📋 List channels ✅ Found 3 channel(s) ? Select a channel to view details: ❯ 📱 WHATSAPP - +15557986280 💬 FACEBOOK - My Business Page 💼 SLACK - My Workspace ← Back to main menu ``` ### Channel Details View ``` 📱 WHATSAPP Channel Details ────────────────────────────────────────────────── Type: whatsapp Identifier: 647834045087314 Created: 2/3/2025, 10:45:46 AM Phone Number: +15557986280 Phone ID: 647834045087314 Status: CONNECTED Quality: GREEN WABA ID: 1390592278829291 Metadata: Verified Name: My Business Country: USA ────────────────────────────────────────────────── Press Enter to continue... ``` ## Link New Channel ### WhatsApp Setup ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 📱 WhatsApp ? Enter phone number ID: 647834045087314 ? Enter WhatsApp Business Account ID (WABA ID): 1390592278829291 ? Enter access token: **** 📡 Creating WhatsApp channel... ✅ WhatsApp channel created successfully! 📱 Channel Details: Phone Number: +15557986280 Status: CONNECTED Webhook: https://wa.heylua.ai/whatsapp/webhook/... ``` **Next Steps for WhatsApp:** 1. Configure webhook in Meta Business Suite 2. Copy webhook URL from output 3. Set webhook URL in Meta dashboard 4. Verify webhook connection ### Facebook Setup ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 💬 Facebook Messenger ? Enter Facebook page access token: **** ? Enter Facebook page ID: 705555819301071 📡 Creating Facebook channel... ✅ Facebook channel created successfully! 💬 Page Name: My Business Page 📄 Page ID: 705555819301071 🔗 Webhook: https://wa.heylua.ai/fb/webhook ``` ### Email Setup Email channels support two modes: **generated inbox** (Lua creates an address) and **existing email** (forward from your own address). ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 📧 Email ? Select email channel mode: 📬 Generate new inbox ? Enter display name (shown in email header): Support Team 📡 Creating Email channel... ✅ Email channel created successfully! 📧 Display Name: Support Team 📬 Email Address: agent-abc123@mail.heylua.ai ──────────────────────────────────────────────────── Your agent's email inbox has been created. ──────────────────────────────────────────────────── Share this address with your customers or use it in your workflows: agent-abc123@mail.heylua.ai Emails sent to this address will be handled by your agent. ──────────────────────────────────────────────────── ``` ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 📧 Email ? Select email channel mode: 📧 Use existing email ? Enter display name (shown in email header): Support Team ? Enter sender email address: support@mybusiness.com 📡 Creating Email channel... ✅ Email channel created successfully! 📧 Display Name: Support Team 📧 Sender Email: support@mybusiness.com 📬 Forward To: b5469c03-082e-481d-929b-663daf66bbef@mail.heylua.ai ──────────────────────────────────────────────────── IMPORTANT: Email Forwarding Setup Required ──────────────────────────────────────────────────── 1. Log into your email provider's settings 2. Set up email forwarding or filtering 3. Forward all emails from support@mybusiness.com to: b5469c03-082e-481d-929b-663daf66bbef@mail.heylua.ai 4. Test by sending an email to support@mybusiness.com Your agent will respond automatically! ──────────────────────────────────────────────────── ``` ### Slack Private Setup ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 🔒 Slack (Private) ? Enter Slack bot token (xoxb-...): **** 📡 Creating Slack channel... ✅ Slack channel created successfully! 💼 Bot Name: My Agent Bot 🏢 Workspace: My Workspace 🔗 Webhook: https://wa.heylua.ai/slack/webhook ``` ### Slack Public Setup ```bash theme={null} ? What would you like to do? 🔗 Link new channel ? Select channel type: 🌐 Slack (Public) ? Enter Slack app ID: A09J8J6T6H0 ? Enter client ID: 1234567890.123456789012 ? Enter client secret: **** 📡 Creating Slack app... ✅ Slack app created successfully! 📱 App ID: A09J8J6T6H0 🔗 Redirect URI: https://auth.heylua.ai/slack/oauth_redirect/... 🎯 Webhook: https://wa.heylua.ai/slack/webhook Next Steps: 1. Copy the redirect URI 2. Add to your Slack app OAuth settings 3. Users can now install your Slack app ``` ## Open Admin Dashboard ```bash theme={null} ? What would you like to do? 🌐 Link on admin dashboard 🌐 Opening Lua Admin Dashboard... ✅ Dashboard opened in your browser URL: https://admin.heylua.ai Agent: myAgent ``` Opens the admin interface where you can: * Manage channels visually * Configure OAuth flows * View channel analytics * Test channel connections * Monitor message delivery ## Use Cases ### Multi-Channel Agent ```bash theme={null} # Connect all your channels $ lua channels → Link WhatsApp → Link Facebook → Link Email → Link Slack → Link Instagram # Your agent now responds on all platforms! ``` ### Channel Migration ```bash theme={null} # Moving from old WhatsApp number # 1. List current channels $ lua channels → List # Note: Old number 555-0000 # 2. Add new channel $ lua channels → Link WhatsApp # New number: 555-1111 # 3. Verify both working $ lua channels → List # See both channels # 4. Remove old channel via admin $ lua admin # Delete old WhatsApp channel ``` ### Team Collaboration ```bash theme={null} # Add Slack for internal team $ lua channels → Link Slack # Now team can chat with agent # Useful for testing and demos ``` ## Channel-Specific Features ### WhatsApp **Phone Number Requirements:** * Must be registered with WhatsApp Business * Verified with Meta Business Suite * Has active WABA (Business Account) **Status Indicators:** * **CONNECTED**: Ready to send/receive * **DISCONNECTED**: Needs reconnection * **PENDING**: Setup in progress **Quality Ratings:** * **GREEN**: High quality, good delivery * **YELLOW**: Medium quality, some issues * **RED**: Low quality, delivery problems ### Facebook **Page Requirements:** * Business or Creator page (not personal profile) * Page access token with messaging permissions * Page must be published **Capabilities:** * Respond to page messages * Handle conversation threads * Access messenger features ### Email **Two modes available:** * **Generated inbox** - Lua creates an email address; no forwarding setup needed * **Existing email** - Uses your own address; requires email forwarding **Forwarding Setup (existing email mode only):** * Must forward to the provided Lua address * Can use email rules/filters * Test after setup **Response Behavior:** * Agent replies to incoming emails * Uses configured display name * Threading supported * Attachments handled ### Slack **Private (Bot Token):** * Simple bot installation * Direct messages * Channel mentions * App home tab **Public (OAuth App):** * Users install your app * Workspace-wide presence * OAuth consent flow * More complex setup ## Troubleshooting **Error:** ``` ❌ No agent ID found in lua.skill.yaml ``` **Solution:** ```bash theme={null} lua init ``` **Error:** ``` ❌ No Lua CLI authentication found ``` **Solution:** ```bash theme={null} lua auth configure ``` **Error:** ``` ❌ Channel already exists 💡 This WhatsApp number is already connected ``` **Solution:** * List channels to see existing * Use different number/account * Remove old channel first (via admin) **Error:** ``` ❌ Invalid access token ``` **Solution:** * Verify token is correct * Check token hasn't expired * Regenerate token from platform * Try again with new token **Problem:** Agent not responding to emails **Check:** 1. Email forwarding is configured 2. Forward address is correct 3. Emails are being forwarded 4. Test by sending email 5. Check admin dashboard for errors ## Best Practices ```bash theme={null} # After linking channel $ lua channels → List # Verify channel appears # Test by sending message # WhatsApp: Send to connected number # Email: Send to configured address # Slack: Message the bot ``` ```bash theme={null} # Complex OAuth flows easier in admin $ lua channels → Link on admin dashboard # Or directly: $ lua admin # Navigate to Channels section ``` ```bash theme={null} # Keep track of what's connected $ lua channels → List # Document: - WhatsApp: +1555-798-6280 - Facebook: Business Page - Email: support@company.com - Slack: Team workspace ``` ```bash theme={null} # Monthly review $ lua channels → List # Check: - All channels CONNECTED - No expired tokens - Quality ratings GREEN - Remove unused channels ``` ## Common Workflows ### Initial Setup ```bash theme={null} # 1. Create agent $ lua init # 2. Deploy skills $ lua push $ lua deploy # 3. Connect channels $ lua channels → Link WhatsApp → Link Facebook → Link Email # 4. Test channels # Send messages on each platform # Verify agent responds ``` ### Add New Channel ```bash theme={null} # Existing agent, add channel $ lua channels → Link new channel → Select channel type → Enter credentials ✅ Channel created # Verify $ lua channels → List # See new channel in list ``` ### Channel Audit ```bash theme={null} # Review all channels $ lua channels → List # For each channel: → Select channel → Note status and details → Back to list # Document findings # Update any needed ``` ## Integration with Admin Dashboard The CLI command and admin dashboard work together: **Use CLI when:** * Setting up via terminal * Scripting channel creation * Quick channel listing * During development workflow ```bash theme={null} lua channels ``` **Use Admin when:** * Visual interface preferred * OAuth flows needed * Managing many channels * Viewing analytics * Team collaboration ```bash theme={null} lua admin # Or lua channels → Link on admin dashboard ``` ## Channel Requirements by Platform ### WhatsApp Business **Before you start:** 1. Register with Meta Business Suite 2. Create WhatsApp Business Account 3. Add phone number 4. Get API access 5. Obtain access token **Then:** ```bash theme={null} lua channels → Link WhatsApp # Enter: Phone ID, WABA ID, Token ``` ### Facebook Messenger **Before you start:** 1. Create Facebook Business Page 2. Create Facebook App 3. Add Messenger product 4. Get page access token **Then:** ```bash theme={null} lua channels → Link Facebook # Enter: Page token, Page ID ``` ### Email **Generated inbox -- before you start:** 1. Choose a display name for your agent **Then:** ```bash theme={null} lua channels → Link Email # Select: Generate new inbox # Enter: Display name # Done -- share the generated address ``` **Existing email -- before you start:** 1. Have email address ready 2. Access to email provider settings 3. Ability to configure forwarding **Then:** ```bash theme={null} lua channels → Link Email # Select: Use existing email # Enter: Display name, Sender email # Configure forwarding as instructed ``` ### Slack **Private (Simple):** 1. Create Slack app 2. Add bot token scopes 3. Install to workspace 4. Copy bot token ```bash theme={null} lua channels → Link Slack (Private) # Enter: Bot token ``` **Public (OAuth):** 1. Create Slack app 2. Configure OAuth 3. Set redirect URLs 4. Get credentials ```bash theme={null} lua channels → Link Slack (Public) # Enter: App ID, Client ID, Secret ``` ## Security **Token Security:** * All tokens are masked during input * Never logged or displayed * Stored securely server-side * Use environment-specific tokens **Best practices:** * Use test accounts in development * Production tokens in production only * Rotate tokens regularly * Revoke compromised tokens immediately ## Next Steps Visual channel management Add website chat channel Test agent responses Monitor live channels # Chat Command Source: https://docs.heylua.ai/cli/chat-command Interactive command-line chat with your AI agent ## Overview The `lua chat` command provides an interactive command-line interface for conversing with your Lua AI agent in both sandbox and production environments. ```bash theme={null} lua chat ``` **Want to test on real channels?** You can also test your agent on WhatsApp, Facebook, Instagram, Email, and Slack without setting up your own channels. See [Quick Testing Channels](/channels/quick-testing) for instant testing on any platform. ## Features Test with local skill overrides and persona customizations Chat with your live production agent Continuous conversation until exit All skills automatically included in sandbox ## Prerequisites ```bash theme={null} lua auth configure ``` ```bash theme={null} lua init ``` Ensures `lua.skill.yaml` exists ```bash theme={null} lua push ``` Required for sandbox testing ## How It Works ``` ✅ Authenticated ``` Validates your API key and retrieves user data ``` ? Select environment: 🔧 Sandbox (with skill overrides) 🚀 Production ``` Choose between sandbox (testing) or production (live) ``` 🔄 Compiling skill... 🔄 Pushing skills to sandbox... ✅ Pushed 2 skills to sandbox ``` Compiles and deploys your local skills to sandbox ``` ============================================================ 💬 Lua Chat Interface Environment: 🔧 Sandbox Press Ctrl+C to exit ============================================================ 🤖 Assistant: Hi there! How can I help you today? 👤 You: ``` Interactive conversation begins ## Sandbox vs Production **For Development & Testing** ```bash theme={null} $ lua chat ? Select environment: 🔧 Sandbox ``` **Features:** * ✅ Local skill overrides * ✅ Persona customization * ✅ Environment variables from `.env` * ✅ Test before deploying **Setup time:** \~10-30 seconds (includes compilation) **Use when:** * Developing new features * Testing skill changes * Iterating on persona * Before pushing to production **For Validation** ```bash theme={null} $ lua chat ? Select environment: 🚀 Production ``` **Features:** * ✅ Production skills only * ✅ Live production persona * ✅ Real environment variables * ✅ Verify deployed changes **Setup time:** \~1-2 seconds **Use when:** * Validating deployed changes * Testing production experience * Verifying skill interactions ## Example Session ### Sandbox Mode ```bash theme={null} $ lua chat ✅ Authenticated ? Select environment: 🔧 Sandbox (with skill overrides) 🔄 Setting up sandbox environment... 🔄 Compiling skill... ✅ Skill compiled successfully - 3 tools bundled 🔄 Pushing skills to sandbox... ✅ Pushed 2 skills to sandbox ============================================================ 💬 Lua Chat Interface Environment: 🔧 Sandbox Press Ctrl+C to exit ============================================================ 🤖 Assistant: Hi there! How can I help you today? 👤 You: What's the weather in London? 🤖 Assistant: ... 🤖 Assistant: The current weather in London is 15°C and cloudy with light wind. 👤 You: Search for laptops 🤖 Assistant: ... 🤖 Assistant: I found 5 laptops in our catalog: 1. MacBook Pro - $1999 2. Dell XPS 13 - $1299 3. ThinkPad X1 - $1499 ... 👤 You: ^C 👋 Goodbye! ``` ## Persona Override ### Configuration In `lua.skill.yaml`: ```yaml theme={null} agent: agentId: "agent_abc123" organizationId: "org_xyz789" persona: | You are a helpful customer service assistant. You help users with product inquiries and order management. Be friendly, professional, and concise. ``` **In Sandbox Mode:** * Persona is automatically loaded and sent with each request * Test different persona variations * Iterate quickly **In Production Mode:** * Uses production persona (from server) * No local override ## Skill Override ### How It Works **Sandbox mode automatically:** 1. Compiles all skills in your project 2. Pushes to sandbox environment 3. Gets sandbox IDs for each skill 4. Includes all sandbox IDs in chat requests **Example override:** ```json theme={null} { "skillOverride": [ { "skillId": "skill_abc123", "sandboxId": "sandbox_def456" } ] } ``` The AI uses your local sandbox versions instead of production versions. ## Thread Isolation By default, `lua chat` uses your agent's shared conversation context. Use the `--thread` flag to scope a session to an isolated thread — useful for running consecutive tests without state leaking between runs. ### Usage ```bash theme={null} # Scope to an explicit thread ID lua chat --thread my-test-scenario # Auto-generate a fresh thread ID (printed at session start) lua chat --thread # Reuse a thread across multiple non-interactive messages lua chat -m "step 1" --thread test-flow lua chat -m "step 2" --thread test-flow # Isolated test: scoped thread, auto-cleared on exit lua chat -m "run test" -t --clear # Explicit thread with auto-clear lua chat -m "run test" -t my-test --clear ``` ### Flags | Flag | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `-t, --thread [id]` | Scope to a thread. Omit the ID to auto-generate a UUID. The active thread ID is always printed at session start. | | `--clear`, `--clear-thread` | Clear the thread's history when the session ends (interactive: on exit, non-interactive: after response). Requires `--thread`. | ### Clearing a Thread Manually ```bash theme={null} lua chat clear --thread my-test-scenario ``` To clear another user's history, pass a user ID, email address, or mobile number: ```bash theme={null} lua chat clear --user user@example.com lua chat clear --user +1234567890 --thread my-test-scenario --force ``` `--user` requires `org:manage` for the agent. Organization-admin grants do not cascade to private agents; organization owners and sufficient explicit agent grants retain access under the shared authorization rules. Without `--user`, `lua chat clear` is strictly self-scoped. ### Testing Workflow Example Run 10 isolated test cases against your agent, each with a clean slate: ```bash theme={null} for i in $(seq 1 10); do lua chat -m "test scenario $i" -t "test-run-$i" --clear -e sandbox done ``` ## File Attachments You can attach files to any message using `@` syntax — in both interactive and non-interactive mode. ```bash theme={null} # Attach an image @/path/to/screenshot.png what's wrong with this UI? # Attach a document @report.pdf summarize this # Mix text and attachment check @screenshot.png and tell me what you see ``` The `@` must appear at the start of your message or after a space. Email addresses (`user@example.com`) are never treated as attachments. ### Supported File Types Sent natively to vision-capable models. | Extension | Type | | --------------- | ------------ | | `.png` | PNG image | | `.jpg`, `.jpeg` | JPEG image | | `.gif` | GIF image | | `.webp` | WebP image | | `.bmp` | Bitmap image | | `.tiff`, `.tif` | TIFF image | | `.heic` | HEIC image | If the model natively supports the format it is sent as-is; otherwise it is converted to an LLM-friendly format automatically. | Extension | Type | | --------------- | ----------------------- | | `.pdf` | PDF document | | `.doc`, `.docx` | Word document | | `.xls`, `.xlsx` | Excel spreadsheet | | `.ppt`, `.pptx` | PowerPoint presentation | | `.odt` | OpenDocument text | | `.epub` | E-book | | Extension | Type | | --------------- | ---------------------- | | `.txt` | Plain text | | `.md` | Markdown | | `.csv` | Comma-separated values | | `.tsv` | Tab-separated values | | `.html`, `.htm` | HTML | | `.xml` | XML | | `.json` | JSON | | `.rtf` | Rich text | | `.rst` | reStructuredText | | `.org` | Org-mode | | Extension | Type | | --------- | --------------- | | `.eml` | Email message | | `.msg` | Outlook message | Files with unsupported extensions are left as plain text in your message — they are never silently stripped. **Model support required.** Attachment support depends on the model configured for your agent. If the model doesn't support vision or file inputs, attachments won't be processed — even if the CLI sends them successfully. Check your agent's model configuration if attachments aren't being picked up. ### Limits * Maximum file size: **10 MB** per attachment * Multiple attachments per message are supported ### Non-Interactive Mode The same `@` syntax works in `-m` / `--message` flags: ```bash theme={null} lua chat -m "@screenshot.png what do you see?" -e production lua chat -m "review @report.pdf and @notes.txt" -e sandbox ``` ## Batch Mode Test how your agent handles concurrent or rapid-fire messages with the `-b/--batch` flag. Useful for stress-testing skills that share state or for reproducing race conditions reported in production. ```bash theme={null} # Send 3 messages concurrently lua chat --batch "list my orders" "what's the status of order 1" "cancel order 1" # Add a delay (ms) between dispatches lua chat --batch "msg 1" "msg 2" "msg 3" --delay 250 ``` | Option | Description | | --------------------------- | ------------------------------------------------------------------------------------------------- | | `-b, --batch ` | Dispatch multiple messages and print every reply. Each message is treated as an independent turn. | | `-d, --delay ` | Delay between batch messages in milliseconds (default: `100`). | Batch mode is **not** a way to scriptably hold a multi-turn conversation — each batched message is a separate turn. For ordered scripted conversations, use repeated `lua chat -m "..."` calls with `-t ` instead. ## Keyboard Shortcuts | Shortcut | Action | | -------- | ----------------------- | | `Enter` | Send message | | `Ctrl+C` | Exit chat | | `Ctrl+D` | Exit chat (alternative) | ## Best Practices 1. Make changes to your skills 2. Run `lua chat` in sandbox mode 3. Test changes interactively 4. Iterate until satisfied 5. Run `lua push` to deploy 6. Test again in production mode 7. Deploy with `lua deploy` **Start with sandbox:** * Test happy paths * Test error cases * Test edge cases * Test multi-step flows **Validate with production:** * Verify deployed changes work * Test with production data * Confirm no regressions 1. Update `persona` in your `LuaAgent` code (`src/index.ts`) 2. Run `lua chat` in sandbox 3. Test conversation style 4. Refine persona 5. Repeat until satisfied 6. Deploy to production with `lua push persona` ## Active log probe (`agent_error` after every turn) After every `lua chat` turn — interactive or `-m` non-interactive — the CLI runs a quiet `lua logs --type agent_error` probe scoped to the current session. The probe checks for **server-side pipeline errors that don't always surface in the chat response itself** (billing failures, schema validation failures, LLM provider errors, post-processor errors). **When the probe is silent**, no agent errors fired during your turn — you're clean. **When new errors fire**, the CLI prints a one-line warning at the end of the turn: ``` ⚠️ 2 new agent error(s) during this turn — run `lua logs --type agent_error --limit 2` to inspect. ``` **Empty response:** If the agent returns **no text** (empty stream), the CLI warns: `⚠️ The agent returned an empty response` and points you at `agent_error` and `runtime` logs so you can see what failed on the server. Run the suggested command verbatim to inspect the errors. To opt out (for example in CI that captures only command output), set `LUA_NO_HINTS=1`: ```bash theme={null} LUA_NO_HINTS=1 lua chat -m "test" -e production ``` ### Debugging recipe ```bash theme={null} # Send one isolated test, then check for pipeline errors lua chat -m "test" -t debug-1 --clear && lua logs --type agent_error --limit 5 ``` See [Debugging your agent — the post-deploy loop](/cli/debugging) for the complete flow. ## Troubleshooting **Debugging after a failed chat:** If a chat turn returns a wrong, empty, or error response, the CLI now automatically surfaces a count of `agent_error` logs that fired during that turn. To inspect them, run the suggested `lua logs --type agent_error --limit N` command. See [Debugging your agent — the post-deploy loop](/cli/debugging) for the canonical loop. **Error:** ``` ❌ No Lua CLI authentication found ``` **Solution:** ```bash theme={null} lua auth configure ``` **Error:** ``` ❌ No agent ID found in lua.skill.yaml ``` **Solution:** ```bash theme={null} lua init ``` **Error:** ``` ❌ Compilation failed ``` **Solution:** * Fix TypeScript errors in your code * Check `src/index.ts` for syntax errors * Verify all imports are correct **Error:** ``` ❌ Failed to push skills to sandbox ``` **Solution:** ```bash theme={null} lua push # Deploy skills first lua chat # Then try again ``` **Issue:** Long wait times in sandbox **Causes:** * First request after compilation * Large skill bundles * Network latency **Solution:** Subsequent messages will be faster **Issue:** Persona override not being applied **Check:** * Using sandbox mode (not production) * `agent.persona` exists in `lua.skill.yaml` * Persona is properly formatted YAML ## Related Commands Canonical push → test → check flow + the active log probe Inspect agent\_error and tool execution logs Test individual tools with specific inputs Deploy skills to server ## Next Steps Test tools one at a time Push your skills live # Debugging Skills Source: https://docs.heylua.ai/cli/debugging How to inspect tool return values and debug skill behavior at runtime ## The Core Problem A common debugging mistake is deploying five times to fix the same bug, when a single log line plus one deployment would have shown you the root cause immediately. If you're patching against test failures rather than the actual return shape, stop and add a `console.log` first — then deploy once. ## The 5-Step Debug Loop Log the actual value — not a guess, the real thing: ```typescript theme={null} async execute(input: any) { const results = await Data.search('articles', input.query, 5, 0.7); // ✅ Log the raw return value FIRST console.log('Data.search result:', JSON.stringify(results, null, 2)); // Don't touch the rest of the code yet return results.map(entry => ({ id: entry.id, title: entry.title })); } ``` Log the entire object so you see the actual shape, not what you assumed it would be. ```bash theme={null} lua push ``` Or use sandbox mode for even faster iteration — no push needed: ```bash theme={null} lua chat # Select "Sandbox" when prompted ``` In sandbox mode, `lua chat` compiles and uses your local code directly. Use this for rapid iteration before committing to a push. ```bash theme={null} # Non-interactive: send a single message and exit lua chat -m "search for thriller movies" ``` Or in interactive mode: ```bash theme={null} lua chat # Type your test message, then Ctrl+C ``` Send the **minimum message needed to trigger the tool**. Don't run a full conversation — you need one clean execution to inspect. ```bash theme={null} # See the most recent 10 skill executions lua logs --type skill --limit 10 # Filter to a specific skill by name lua logs --type skill --name my-skill --limit 5 # JSON output for piping/scripting lua logs --type skill --json | head -100 ``` Your `console.log` output appears in the log message body. Look for the `🔍 DEBUG` or `ℹ️ INFO` entries — they contain your logged values. **Example output:** ``` 🔍 [4/28/2026, 11:45:30 AM] DEBUG Skill Name: search-skill Skill ID: skill_abc123 Tool Name: search_articles Data.search result: [ { "id": "entry_xyz", "title": "Inception", "score": 0.92 } ] ``` Now you can see the exact shape of what was returned and fix accordingly. You now know: * **Exact shape** of the return value * **Which fields exist** (and which don't) * **What the actual values** look like Fix the code, push once, verify with `lua logs`. Repeat until correct. ## Suppressing CLI hints All post-action hints can be silenced with: ```bash theme={null} LUA_NO_HINTS=1 lua push all --force --auto-deploy ``` Set `LUA_NO_HINTS=true` (or `yes`) in your shell profile or CI environment to disable hints globally. This is useful in CI/CD pipelines that capture only command output. ## Reading lua logs Output ```bash theme={null} lua logs --type skill --limit 20 ``` ### Log entry anatomy ``` ✅ [4/28/2026, 11:45:30 AM] COMPLETE Skill Name: my-search-skill Skill ID: skill_abc123 Tool Name: search_articles Duration: 234ms Execute function completed ``` | Field | Meaning | | ---------------- | -------------------------------------------------- | | Icon + timestamp | When the log was created | | Log type | ERROR, WARN, DEBUG, INFO, START, COMPLETE | | Skill/Tool Name | Which component ran | | Duration | Execution time in ms | | Message | The actual log content (your `console.log` output) | ### Log types | Type | Color | Means | | ---------- | ------ | ----------------------------------------------- | | ❌ ERROR | Red | Tool threw an exception — check the stack trace | | ⚠️ WARN | Yellow | Non-fatal issue, tool continued | | 🔍 DEBUG | Blue | `console.log` output from your tool code | | ℹ️ INFO | Cyan | Operational status messages | | ▶️ START | Green | Tool execution began | | ✅ COMPLETE | Green | Tool execution finished successfully | ### Filter by component type ```bash theme={null} # Skill tool execution lua logs --type skill --limit 20 # Webhook execution lua logs --type webhook --name stripe-webhook --limit 10 # Scheduled job execution lua logs --type job --name daily-report --limit 5 # Preprocessor/Postprocessor lua logs --type preprocessor --limit 10 lua logs --type postprocessor --limit 10 # User messages and agent responses lua logs --type user_message --limit 20 lua logs --type agent_response --limit 20 # All logs lua logs --type all --limit 50 ``` ## When to Use lua test vs lua chat vs lua logs | Goal | Use | | --------------------------------------------------------------- | ----------------------------------- | | Test a tool with exact input before touching the server | `lua test` | | Test a conversational flow, verify the AI calls the right tools | `lua chat` (sandbox) | | See what actually ran in production | `lua logs --type skill` | | Debug why an API call returned unexpected data | `console.log` + push + `lua logs` | | Check if a job actually ran and what it returned | `lua logs --type job --name my-job` | | Verify a webhook received and processed correctly | `lua logs --type webhook` | ## console.log Debugging Patterns ### Log a full API return value ```typescript theme={null} const results = await Data.search('articles', input.query, 5, 0.7); console.log('[DEBUG] Data.search result type:', typeof results, Array.isArray(results) ? `array[${results.length}]` : 'not array'); console.log('[DEBUG] Data.search result:', JSON.stringify(results, null, 2)); ``` ### Log individual entries ```typescript theme={null} const results = await Data.search('articles', input.query, 5); results.forEach((entry, i) => { console.log(`[DEBUG] entry[${i}]:`, JSON.stringify({ id: entry.id, score: entry.score, keys: Object.keys(entry.data || {}), sample: entry.title || entry.data?.title || '(no title field)' })); }); ``` ### Log before and after transformation ```typescript theme={null} const raw = await Data.get('orders', { status: 'pending' }); console.log('[DEBUG] Data.get result — pagination:', JSON.stringify(raw.pagination)); console.log('[DEBUG] Data.get result — entry count:', raw.data.length); if (raw.data.length > 0) { console.log('[DEBUG] First entry sample:', JSON.stringify(raw.data[0])); } const mapped = raw.data.map(entry => ({ id: entry.id, orderId: entry.data.orderId, total: entry.data.total })); console.log('[DEBUG] After mapping:', JSON.stringify(mapped.slice(0, 2))); ``` ## Common Bugs and How to Spot Them ### Bug: results.data is undefined (Data.search) ``` [DEBUG] Data.search result type: object array[3] [DEBUG] Data.search result: [{"id":"entry_abc","data":{"title":"Inception"},"score":0.92}] ``` `results.data` would be `undefined` — there is no `.data` wrapper on the array. Use `results[0].data.title` for the entry payload, or `results[0].title` via the Proxy shortcut. ### Bug: entry.title is undefined (Data.get) ``` [DEBUG] First entry sample: {"id":"entry_xyz","data":{"title":"The Matrix"}} ``` `entry.title` would be `undefined` — `Data.get` entries are raw, not proxied. Use `entry.data.title`. ### Bug: products.data is undefined (Products.search) ``` [DEBUG] Products.search result type: object [DEBUG] Products.search result: {"products":[{"id":"prod_1","name":"Laptop"}]} ``` `results.data` doesn't exist on `ProductSearchInstance`. Use `results.products` or `results.map(p => p.name)`. ## Removing Debug Logs Before Production Once the bug is fixed, clean up your logs. Production logs are visible to your whole team and consume log storage. ```typescript theme={null} // ❌ Don't leave these in production code console.log('[DEBUG] raw result:', JSON.stringify(results)); // ✅ Leave meaningful operational logs console.log(`Processed ${results.length} search results for query: "${input.query}"`); ``` ## Related Full reference for the logs command and all filter options Test tools locally before pushing Data API return shapes reference Common errors and solutions # Devices Command Source: https://docs.heylua.ai/cli/devices-command Manage connected IoT and physical devices — enable, disable, test commands, and fire triggers ## Overview `lua devices` manages the registered devices your agent can command and receive triggers from — IoT sensors, label printers, smart-home gear, kiosks, etc. The command lets you list, enable, disable, remove, and test devices and their triggers without writing any code. ```bash theme={null} lua devices # Interactive management lua devices list # List all devices lua devices status --device-name label-printer lua devices test --device-name label-printer ``` For defining devices in code, see the [Devices](/devices/overview) tab. For building your own device client, see [Build Your Own](/devices/build-your-own). ## Subcommands | Action | What it does | | -------------- | -------------------------------------------------------------------------- | | `list` | List all registered devices (optionally filter by `--group`). | | `status` | Print online/offline/disabled state and last-seen time for a device. | | `enable` | Re-enable a previously disabled device. | | `disable` | Stop the agent from issuing commands to a device (offline mode). | | `remove` | Unregister a device. Use `--force` to skip the confirmation. | | `test` | Send a test command to a device. Prompts interactively or use `--payload`. | | `test-trigger` | Fire a test trigger as if it came from the device. | ## Options | Option | Description | | ---------------------- | --------------------------------------------------------- | | `--device-name ` | Device name. Required for most non-interactive actions. | | `--group ` | Filter device list by group (use with `list`). | | `--payload ` | JSON payload for `test` / `test-trigger` (default: `{}`). | | `--timeout ` | Command timeout in milliseconds (default: `30000`). | | `--force` | Skip confirmation prompts (used with `remove`). | ## Examples ```bash theme={null} # Interactive picker lua devices # List all devices lua devices list # Filter list by group lua devices list --group printers # Inspect device state lua devices status --device-name label-printer # Enable / disable lua devices enable --device-name label-printer lua devices disable --device-name label-printer # Remove (confirms before deleting) lua devices remove --device-name label-printer lua devices remove --device-name label-printer --force # Test a command (interactive payload) lua devices test --device-name label-printer # Test with a specific payload lua devices test --device-name label-printer \ --payload '{"command":"print","data":{"label":"order-1234"}}' # Fire a test trigger lua devices test-trigger --device-name label-printer \ --payload '{"event":"button_pressed","data":{"button":"red"}}' ``` ## Test Command vs Test Trigger | Command | Direction | When to use | | -------------- | -------------- | ----------------------------------------------------------------------------- | | `test` | Agent → device | Verify your agent can send commands to a device and that the device responds. | | `test-trigger` | Device → agent | Verify your agent reacts correctly when a trigger fires from a device. | ## Common Workflow After registering a new device: ```bash theme={null} lua devices list # Confirm registration lua devices status --device-name new-device # Check it's online lua devices test --device-name new-device # Verify command path lua devices test-trigger --device-name new-device # Verify trigger path lua logs --type device --name new-device --limit 10 # Inspect logs lua logs --type device-trigger --name new-device # Inspect triggers ``` ## Related * [Devices Overview](/devices/overview) * [Self-Describing Commands](/devices/self-describing-commands) * [Device Triggers](/devices/triggers) * [Agent Tools for Devices](/devices/agent-tools) * [Logs Command](/cli/logs-command) — filter by `--type device` or `--type device-trigger` # Environment Variables Command Source: https://docs.heylua.ai/cli/env-command Manage environment variables for sandbox and production ## Overview The `lua env` command provides an interactive interface for managing environment variables in both sandbox (development) and production environments. ```bash theme={null} lua env # Interactive: choose environment lua env sandbox # Direct: manage .env file lua env staging # Direct: alias for sandbox lua env production # Direct: manage production API vars ``` Direct environment access lets you skip the selection prompt for faster workflows! Full non-interactive mode with `-k`, `-v`, `--list`, and `--delete` flags for scripting and CI/CD. ### Non-Interactive Mode ```bash theme={null} # List variables lua env sandbox --list lua env production --list # Set a variable lua env sandbox -k DATABASE_URL -v "postgres://localhost/db" lua env production -k API_KEY -v "sk_live_xxx" # Delete a variable lua env production -k OLD_KEY --delete ``` | Option | Description | | ------------------- | ------------------------------ | | `--list` | List all environment variables | | `-k, --key ` | Variable name | | `-v, --value ` | Variable value | | `-d, --delete` | Delete the specified variable | Manage `.env` file locally Manage variables on server via API Add, update, delete, and view variables Values masked in list view ## Usage Modes **Default behavior - prompts for environment** ```bash theme={null} $ lua env ? Select environment: › 🔧 Sandbox (.env file) 🚀 Production (API) ``` Best for: When you're not sure which environment **Skip prompt and go straight to sandbox** ```bash theme={null} $ lua env sandbox # No prompt - opens .env file management immediately ``` Best for: Quick local configuration during development **Skip prompt and go straight to production** ```bash theme={null} $ lua env production # No prompt - opens production API management immediately ``` Best for: Fast production updates, automation ## Quick Start ```bash theme={null} lua env sandbox # Direct to sandbox # or lua env # Interactive ``` ``` ? Select environment: 🔧 Sandbox (.env file) 🚀 Production (API) ``` * ➕ Add new variable * ✏️ Update existing * 🗑️ Delete variable * 👁️ View full value ## Environment Selection **Local Development** Manages your `.env` file in the project directory. ``` ? Select environment: 🔧 Sandbox (.env file) ``` **Features:** * ✅ No authentication required * ✅ Instant changes * ✅ Local file management * ✅ Used by `lua test` and `lua chat` **File location:** `PROJECT_ROOT/.env` **Production Environment** Manages variables on the server via API. ``` ? Select environment: 🚀 Production (API) ✅ Authenticated ``` **Features:** * ✅ Secure server storage * ✅ Team collaboration * ✅ Audit logging * ✅ Used by deployed skills **Requires:** Valid API key (`lua auth configure`) ## Actions Available ### Add New Variable ```bash theme={null} ? What would you like to do? ➕ Add new variable ? Variable name: STRIPE_API_KEY ? Variable value: sk_test_abc123 🔄 Saving... ✅ Variable "STRIPE_API_KEY" added successfully ``` Must start with letter/underscore, contain only letters, numbers, underscores Any string value (can include spaces, special characters) ### Update Existing Variable ```bash theme={null} ? What would you like to do? ✏️ Update existing variable ? Select variable to update: STRIPE_API_KEY ? New value for STRIPE_API_KEY: sk_test_xyz789 🔄 Saving... ✅ Variable "STRIPE_API_KEY" updated successfully ``` ### Delete Variable ```bash theme={null} ? What would you like to do? 🗑️ Delete variable ? Select variable to delete: OLD_KEY ? Are you sure you want to delete "OLD_KEY"? Yes 🔄 Saving... ✅ Variable "OLD_KEY" deleted successfully ``` Deletion requires confirmation to prevent accidents. Default is "No". ### View Variable Value ```bash theme={null} ? What would you like to do? 👁️ View variable value ? Select variable to view: STRIPE_API_KEY ============================================================ Variable: STRIPE_API_KEY ============================================================ sk_test_abc123xyz789 ============================================================ Press Enter to continue... ``` Shows the full unmasked value for copying. ## Variable Display Variables are **masked for security** in the list view: ``` ============================================================ 📋 Environment Variables (Sandbox) ============================================================ 1. DATABASE_URL = post********************** 2. STRIPE_KEY = sk-t********************** 3. API_SECRET = abc1********************** ``` **Masking rules:** * Shows first 4 characters * Replaces rest with asterisks (max 20) * Values \< 4 chars show only asterisks ## Variable Naming Rules ```bash theme={null} DATABASE_URL ✅ API_KEY ✅ STRIPE_SECRET_KEY ✅ MAX_CONNECTIONS ✅ enable_feature ✅ _INTERNAL_CONFIG ✅ ``` ```bash theme={null} 123_NUMBER ❌ Starts with number MY-VARIABLE ❌ Contains hyphen MY VARIABLE ❌ Contains space MY.VARIABLE ❌ Contains dot ``` ## Use in Development Workflow ### Step 1: Configure Variables ```bash theme={null} # Set up sandbox environment variables lua env sandbox # Add: DATABASE_URL, API_KEY, STRIPE_SECRET, etc. # Set up production environment variables lua env production # Add production API keys and URLs ``` ### Step 2: Test Locally ```bash theme={null} # Variables are automatically loaded lua test # Or test in conversation lua chat ``` ### Step 3: Verify Production Config ```bash theme={null} # Check production variables before deploying lua env production ``` ### Step 4: Deploy ```bash theme={null} lua push lua deploy ``` ## Sandbox vs Production **Local Development** ```bash theme={null} lua env # → Choose Sandbox ``` **Add variables like:** ``` DATABASE_URL=postgresql://localhost:5432/dev STRIPE_KEY=sk_test_... DEBUG=true ``` **Used by:** * `lua test` * `lua chat` (sandbox mode) * Local development **Production Deployment** ```bash theme={null} lua env # → Choose Production ``` **Add variables like:** ``` DATABASE_URL=postgresql://prod-host:5432/prod STRIPE_KEY=sk_live_... DEBUG=false ``` **Used by:** * Deployed skills * `lua chat` (production mode) * Live user interactions ## Best Practices ```bash theme={null} # Sandbox - Test keys STRIPE_KEY=sk_test_abc123 DATABASE=dev_database # Production - Live keys STRIPE_KEY=sk_live_xyz789 DATABASE=prod_database ``` Add to `.gitignore`: ``` .env .env.local .env.*.local ``` Commit `.env.example` instead with placeholder values Create `.env.example`: ```bash theme={null} # Required API Keys STRIPE_KEY=sk_test_your_key_here DATABASE_URL=postgresql://localhost:5432/dbname # Optional DEBUG=true MAX_RETRIES=3 ``` Update sensitive keys every 90 days: * API keys * Database passwords * JWT secrets * Encryption keys ## Example Session ```bash theme={null} $ lua env sandbox # Or interactive: lua env → Choose Sandbox ============================================================ 📋 Environment Variables (Sandbox) ============================================================ ℹ️ No environment variables configured. ? What would you like to do? ➕ Add new variable ? Variable name: DATABASE_URL ? Variable value: postgresql://localhost:5432/myapp 🔄 Saving... ✅ Variable "DATABASE_URL" added successfully ============================================================ 📋 Environment Variables (Sandbox) ============================================================ 1. DATABASE_URL = post********************** ? What would you like to do? ➕ Add new variable ? Variable name: STRIPE_KEY ? Variable value: sk_test_abc123 🔄 Saving... ✅ Variable "STRIPE_KEY" added successfully ============================================================ 📋 Environment Variables (Sandbox) ============================================================ 1. DATABASE_URL = post********************** 2. STRIPE_KEY = sk-t********************** ? What would you like to do? ❌ Exit 👋 Goodbye! ``` ## Troubleshooting **Problem**: Tool can't find environment variable **Solution:** ```typescript theme={null} import { env } from 'lua-cli'; const apiKey = env('STRIPE_KEY'); if (!apiKey) { throw new Error('STRIPE_KEY not configured. Run: lua env'); } ``` **Error**: `EACCES: permission denied` **Solution:** ```bash theme={null} chmod 644 .env ``` **Problem**: Changes don't persist **Solutions:** 1. Verify API key: `lua auth key` 2. Check network connection 3. Try again **Error**: Validation error **Fix:** Use valid format: * Start with letter or underscore * Only letters, numbers, underscores * No hyphens, spaces, or special characters ## Related Commands Uses sandbox environment variables Uses sandbox or production based on mode Doesn't include env vars (stored separately) Uses production environment variables ## Next Steps Complete guide to configuration management Test tools with your environment variables # Features Command Source: https://docs.heylua.ai/cli/features-command Manage agent capabilities and features ## Overview The `lua features` command provides an interactive interface for managing your AI agent's capabilities: **Knowledge Search** (`rag`), **Web Search** (`webSearch`), and **Inquiry Forms** (`inquiry`). For what each feature does and how it fits with resources and the Admin Dashboard, see [Features](/overview/features). Prefer a UI? The same toggles and per-agent context editing are available in the **Admin Dashboard** — see [Features → Admin Dashboard](/overview/features#from-the-admin-dashboard). The CLI and dashboard call the same API, so changes in one surface immediately in the other. ```bash theme={null} lua features ``` Centralized control over agent features with instant enable/disable and context customization! ### Non-Interactive Mode ```bash theme={null} # List all features lua features list # Enable a feature lua features enable --feature-name rag # Disable a feature lua features disable --feature-name rag # View feature details lua features view --feature-name webSearch ``` | Option | Description | | ----------------------- | -------------------------------------------- | | `--feature-name ` | Feature name (`rag`, `webSearch`, `inquiry`) | | Action | Description | Required Options | | --------- | -------------------------------- | ---------------- | | `list` | List all features with status | None | | `enable` | Enable a feature | `--feature-name` | | `disable` | Disable a feature | `--feature-name` | | `view` | View feature details and context | `--feature-name` | See all available features and their status Activate or deactivate features instantly Customize feature instructions Update status and context together ## Available Features ### Knowledge Search (`rag`) RAG-based knowledge base search for retrieving information from your documentation. **Capabilities:** * Semantic search across knowledge base * Category-based search (FAQ, policies, instructions) * Document retrieval with citations * Contextual information retrieval **Best for:** Information agents, documentation assistants, customer support *** ### Web Search (`webSearch`) Real-time internet search capability for accessing current information, with source links attached to the response. Search runs one of two ways depending on your agent's model: * **Native search** — models with a built-in, cited web search path (OpenAI, Anthropic, Google/Gemini, xAI, and Zhipu AI's GLM models) use the provider's own search tool. * **Assisted search** — every other model falls back to Lua's own web-search tool. Either way, responses come back with the same source-citation format, so your agent's behavior doesn't change based on which model it's using. **Important:** The Web Search feature is not a web scraper. It cannot navigate to or read specific URLs directly, and it does not scrape content from individual or bulk pages. **Capabilities:** * Search the web in real-time * Retrieve up-to-date information and current events * Return synthesized answers with source citations **Best for:** Research agents, information lookup, real-time data needs **How to Enable:** * **Enabled by default** for new agents, like the other built-in features. Native search can add latency and per-search cost on some models, so disable it if your agent doesn't need it. * Can be toggled on or off by running `lua features enable --feature-name webSearch` or `lua features disable --feature-name webSearch` in the CLI. *** ### Inquiry Forms (`inquiry`) Define conversational forms so the agent can collect structured answers from users. Submissions are visible in the **Admin Dashboard**. Inquiry forms are general-purpose (leads, intake, feedback, or support-style flows)—they replaced the older standalone “support tickets” feature. **Capabilities:** * Generate inquiry forms from your form definitions * Collect and track submissions * Add comments and manage follow-up in the dashboard **Best for:** Sales, lead capture, support intake, or any workflow that needs structured data from conversations ## Quick Start ```bash theme={null} lua features ``` See all available features with their current status: ``` Available features: 1. ✅ Knowledge Search (RAG) Name: rag Status: Active 2. ❌ WebSearch Name: webSearch Status: Inactive 3. ❌ Create Inquiry forms Name: inquiry Status: Inactive ``` Select what you want to do: * View feature details * Manage a feature (enable/disable/update) * Refresh list ## Main Menu Actions **See complete information about a feature** ```bash theme={null} ? What would you like to do? View feature details ? Select feature: ✅ Knowledge Search (RAG) ============================================================ Feature: Knowledge Search (RAG) ============================================================ Name: rag Status: ✅ Active Context/Instructions: Search the knowledge base for information. Use categories: FAQ, policies, instructions. Always cite sources when providing information. Press Enter to continue... ``` **Use for:** Understanding what a feature does before enabling **Enable, disable, or update a feature** ```bash theme={null} ? What would you like to do? Manage a feature ? Select feature: ❌ WebSearch ? What would you like to do with "WebSearch"? › Activate feature Update context/instructions Update both status and context Back to main menu ``` **Use for:** Changing feature settings **Reload features from server** ```bash theme={null} ? What would you like to do? Refresh list 🔄 Refreshing features... ✅ Features refreshed successfully ``` **Use for:** Getting latest feature status after external changes ## Managing Features ### Enable a Feature ```bash theme={null} $ lua features ? What would you like to do? Manage a feature ``` ```bash theme={null} ? Select feature: ❌ WebSearch ``` ```bash theme={null} ? What would you like to do? Activate feature ⚠️ This will enable "WebSearch" for your agent. ? Are you sure? Yes 🔄 Updating feature... ✅ Feature "WebSearch" activated successfully ``` ### Disable a Feature ```bash theme={null} $ lua features ? What would you like to do? Manage a feature ? Select feature: ✅ Knowledge Search (RAG) ``` ```bash theme={null} ? What would you like to do? Deactivate feature ⚠️ This will disable "Knowledge Search (RAG)" for your agent. ? Are you sure? Yes 🔄 Updating feature... ✅ Feature "Knowledge Search (RAG)" deactivated successfully ``` ### Update Feature Context Customize how your agent uses a feature by editing its instructions. ```bash theme={null} $ lua features ? What would you like to do? Manage a feature ? Select feature: ✅ Knowledge Search (RAG) ``` ```bash theme={null} ? What would you like to do? Update context/instructions Opening editor to modify feature context... ``` Your system editor opens with the current context: ``` Search the knowledge base for information. Use categories: FAQ, policies, instructions. Always cite sources when providing information. ``` Modify the instructions: ``` Search the knowledge base for product information. Categories available: - FAQ: Common customer questions - policies: Company policies and procedures - instructions: How-to guides and documentation Guidelines: - Always cite sources with document names - If information is not found, say so clearly - Suggest related topics when helpful - Keep responses concise but complete ``` Save and exit your editor. ```bash theme={null} ? Do you want to save these changes? Yes 🔄 Updating feature context... ✅ Context updated successfully ``` ### Batch Update (Status + Context) Update both feature status and context in one operation. ```bash theme={null} $ lua features ? What would you like to do? Manage a feature ? Select feature: ❌ Create Inquiry forms ``` ```bash theme={null} ? What would you like to do? Update both status and context ? New status for "Create Inquiry forms": › Enable Disable ``` Your editor opens for context editing... Save your changes. ```bash theme={null} Summary of changes: - Status: Inactive → Active - Context: Updated ? Do you want to apply these changes? Yes 🔄 Updating feature... ✅ Feature "Create Inquiry forms" updated successfully ``` ## Use Cases ### Customer Support Agent Enable features for internal knowledge and optional intake: ```bash theme={null} $ lua features # Enable Knowledge Search → Manage: ❌ Knowledge Search → Activate ✅ RAG enabled # Optional: inquiry forms for structured support intake (visible in Admin Dashboard) → Manage: ❌ Inquiry Forms → Activate ✅ Inquiry forms enabled # Disable Web Search if you only want org-approved sources → Manage: ✅ WebSearch → Deactivate ✅ WebSearch disabled # Result: Agent focused on your knowledge base (and optional forms) ``` **Features Active:** * ✅ Knowledge Search - Access help docs and policies * ✅ Inquiry Forms (optional) - Structured intake or follow-up * ❌ Web Search - Not needed for internal-only support *** ### Sales Agent Configure for lead generation and product information: ```bash theme={null} $ lua features # Enable Inquiry Forms for lead capture → Manage: ❌ Inquiry Forms → Activate → Update context: "Collect contact info, company size, budget" ✅ Inquiry forms enabled # Enable Knowledge Search for product info → Manage: ❌ Knowledge Search → Activate ✅ RAG enabled # Enable Web Search for competitive research → Manage: ❌ Web Search → Activate ✅ WebSearch enabled # Result: Lead capture + product knowledge + market research ``` **Features Active:** * ✅ Inquiry Forms - Lead generation * ✅ Knowledge Search - Product information * ✅ Web Search - Market research *** ### Information Agent Optimize for information retrieval: ```bash theme={null} $ lua features # Enable RAG as primary source → Manage: ❌ Knowledge Search → Activate → Update context: "Primary information source. Always check here first." ✅ RAG enabled # Enable Web Search as backup → Manage: ❌ Web Search → Activate → Update context: "Use only when RAG doesn't have the answer." ✅ WebSearch enabled # Disable inquiry if you only need read-only answers → Manage: ✅ Inquiry Forms → Deactivate ✅ Focused on information retrieval only ``` **Features Active:** * ✅ Knowledge Search - Primary source * ✅ Web Search - Supplementary data * ❌ Inquiry Forms - No data collection needed ## Customizing Feature Context ### Writing Effective Context Good feature context should include: Explain what the feature is for: ``` ✅ Good: "Use this feature to search our internal knowledge base for product documentation, FAQs, and policies." ❌ Bad: "Search stuff." ``` Specify appropriate situations: ``` ✅ Good: "Use inquiry forms when: - You need structured lead or intake data - The user agrees to submit a form - Follow-up will happen in the Admin Dashboard Do NOT use inquiry forms for: - Simple FAQs already in the knowledge base - One-off questions that RAG can answer" ❌ Bad: "Use forms sometimes." ``` Set boundaries and expectations: ``` ✅ Good: "When using web search: 1. Use for current events and real-time data 2. Verify information with multiple sources 3. Cite sources in your response 4. If search fails, inform user honestly 5. Don't rely on search for company-specific info" ❌ Bad: "Search the web when needed." ``` Provide concrete examples: ``` ✅ Good: "Example inquiry form fields: - Name (required) - Email (required) - Company name - Company size (1-10, 11-50, 51-200, 201+) - Budget range - Timeline for decision - Additional notes" ❌ Bad: "Collect customer information." ``` ### Context Templates #### Knowledge Search Template ```markdown theme={null} Search the internal knowledge base for information. Categories available: - FAQ: Frequently asked questions - policies: Company policies and procedures - instructions: Step-by-step guides - products: Product documentation Search best practices: 1. Try specific queries first 2. Use category filters when possible 3. Always cite the document source 4. If not found, try broader terms 5. Suggest related topics when helpful When information is not found: - Say so clearly and honestly - Offer to escalate if needed - Suggest alternative resources ``` #### Web Search Template ```markdown theme={null} Use web search for real-time, current information. Appropriate for: - Current events and news - Real-time data (stock prices, weather) - Recent industry updates - Public information not in knowledge base Not appropriate for: - Company-specific information (use RAG) - Customer data or internal systems - Proprietary information - Historical company information Always: - Verify information quality - Cite sources with URLs - Cross-reference when possible - Note the date of information ``` #### Inquiry Forms Template ```markdown theme={null} Create inquiry forms to collect lead information. Standard fields to include: - Full name (required) - Email address (required) - Phone number (optional) - Company name - Company size - Industry - Budget range - Timeline - Specific needs/requirements Qualification questions: - What problem are you trying to solve? - Have you used similar solutions? - What's your timeline for implementation? - Who else is involved in the decision? After form submission: - Thank the prospect - Set expectations for response time - Offer immediate resources if available ``` ## Complete Workflows ### Initial Agent Setup ```bash theme={null} # 1. Configure features for your agent type lua features # 2. Enable necessary features → Activate relevant features # 3. Customize each feature's context → Update context for each enabled feature # 4. Test with chat lua chat # Test each feature's behavior # 5. Iterate based on results lua features → Adjust context as needed ``` ### Feature Testing Workflow ```bash theme={null} # 1. Enable test feature lua features → Activate feature → Add initial context # 2. Test in sandbox lua chat # Try using the feature # 3. Review and refine # Based on test results # 4. Update context lua features → Update context with improvements # 5. Test again lua chat # Verify improvements # 6. Deploy when satisfied lua push skill lua deploy ``` ### Feature Optimization ```bash theme={null} # 1. Review current features lua features → View all features and their usage # 2. Disable unused features → Deactivate features not being used # Improves performance and reduces complexity # 3. Optimize active features → Update context for better behavior → Add examples and guidelines # 4. Test optimization lua chat # Verify improvements # 5. Monitor results # Check conversations in admin dashboard lua admin ``` ## Integration with Other Commands ### Features + Persona Coordinate features with persona: ```bash theme={null} # 1. Enable features lua features → Enable: rag, inquiry # 2. Update persona to mention capabilities lua persona sandbox # Add: "I can search our knowledge base and collect details via inquiry forms" # 3. Test together lua chat # 4. Deploy both lua push persona lua push skill ``` ### Features + Skills Build skills that use platform features: ```bash theme={null} # 1. Enable features you need (e.g. RAG + inquiry) lua features → Activate: rag # 2. Add or extend tools in your skill project vim src/tools/MyTool.ts # 3. Test lua test # 4. Deploy lua push skill ``` ### Features + Environment Different features per environment: ```bash theme={null} # Development: Enable all features for testing lua features → Activate: All features # Production: Enable only needed features lua features → Activate: rag, inquiry → Deactivate: webSearch ``` ## Best Practices Enable only features you need: ```bash theme={null} # ✅ Good: Start with essentials lua features → Enable: rag only (add webSearch or inquiry when needed) # Test and add more as needed # ❌ Bad: Enable everything "just in case" → Enable: All features # Adds complexity and potential confusion ``` Detailed instructions improve results: ```bash theme={null} # ✅ Good: Detailed context "Use inquiry forms for technical escalations. Include: problem summary, steps to reproduce, customer impact, and priority level." # ❌ Bad: Vague context "Use forms for problems." ``` Always test feature changes: ```bash theme={null} lua features → Update context lua chat # Immediately test the changes # Verify behavior matches expectations ``` Keep notes on feature configuration: ```yaml theme={null} # In README.md or docs ## Active Features - Knowledge Search (rag): Internal docs only - Web Search: Disabled (use internal knowledge) - Inquiry Forms: Disabled (not collecting structured data) ``` Audit features periodically: ```bash theme={null} # Monthly review lua features → Check active features → Disable unused features → Update context based on learnings → Test improvements ``` ## Troubleshooting **Error**: "Failed to fetch features" **Solutions:** 1. Verify authentication: ```bash theme={null} lua auth key ``` 2. Check lua.skill.yaml has agentId: ```yaml theme={null} agent: agentId: agent_abc123 ``` 3. Test API connection: ```bash theme={null} lua admin # If admin works, features should too ``` **Problem**: Editor opens but changes don't save **Solutions:** 1. Make sure you save in editor (`:wq` for vim) 2. Confirm when prompted after editing 3. Check for error messages 4. Try again with refresh: ```bash theme={null} lua features → Refresh list → Try update again ``` **Problem**: Enabled feature not available in chat **Solutions:** 1. Verify feature is active: ```bash theme={null} lua features # Check status shows ✅ ``` 2. Refresh agent session: ```bash theme={null} lua chat # Start new chat session ``` 3. Check feature context is not empty 4. Redeploy if needed: ```bash theme={null} lua push skill lua deploy ``` **Error**: "Feature update failed" **Solutions:** 1. Check agentId is correct 2. Verify API key has permissions 3. Ensure feature name is valid 4. Check network connection 5. Try refreshing features list first **Problem**: Context update doesn't open editor **Solutions:** 1. Set EDITOR environment variable: ```bash theme={null} export EDITOR=vim # or nano, code, etc. ``` 2. Add to shell config (\~/.bashrc or \~/.zshrc): ```bash theme={null} echo 'export EDITOR=vim' >> ~/.bashrc source ~/.bashrc ``` 3. Try different editor: ```bash theme={null} export EDITOR=nano lua features ``` ## Requirements * **Authentication**: Valid API key (`lua auth configure`) * **Project**: Must be in skill directory with `lua.skill.yaml` * **Agent ID**: Configuration must contain `agent.agentId` * **Permissions**: API key must have agent management permissions ## Related Commands Configure agent personality to work with features Test features in conversation View feature usage in admin dashboard Deploy skills that use features ## Next Steps What each feature does (rag, webSearch, inquiry) Use lua chat to test feature behavior Create tools that leverage features Update persona to mention capabilities Monitor feature usage # Git Command Source: https://docs.heylua.ai/cli/git-command Auto-commit your agent project and optionally push to GitHub ## Overview The `lua git` commands connect your agent project to git, so that Lua operations automatically commit a snapshot of your project. Optionally, each commit can also be pushed to a linked GitHub repository. ```bash theme={null} lua git connect # enable auto-commits for this project lua git auth github # link a GitHub account lua git status # show the current git integration state ``` Git integration is **opt-in** and per-project. Until you run `lua git connect`, Lua never touches your git repository. ## What Auto-Commit Does Once enabled, these commands commit your project automatically after they succeed: | Command | Commits | Also tags | | ----------------------------------------------------------------- | ------- | ---------- | | `lua push` | ✅ | — | | [`lua version create`](/cli/version-command#lua-version-create) | ✅ | `lua/v` | | [`lua version promote`](/cli/version-command#lua-version-promote) | ✅ | — | | [`lua version delete`](/cli/version-command#lua-version-delete) | ✅ | — | | `lua pull` | ✅ | — | Auto-commit is non-blocking: if a commit can't be made, the Lua command still succeeds and prints a short warning. ## Enabling Auto-Commit Auto-commit needs a git repository with an identity configured: ```bash theme={null} git init git config user.name "Your Name" git config user.email "you@example.com" ``` Lua never runs `git init` or `git config` for you — you stay in control of your repository. ```bash theme={null} lua git connect ``` This runs sanity checks (git installed, inside a repository, identity configured) and, on success, records the setting in `lua.skill.yaml`. ## Pushing to GitHub To also push each auto-commit to GitHub, link a GitHub account and enable auto-push. ```bash theme={null} lua git auth github ``` A code is shown in your terminal; open [github.com/login/device](https://github.com/login/device), enter the code, and authorize Lua. The token is stored locally under `~/.lua-cli/`. ```text theme={null} Open https://github.com/login/device and enter the code: WDJB-MJHT ✓ Logged in to GitHub as @your-username. ``` Auto-push targets your `origin` remote, which must be a GitHub HTTPS URL: ```bash theme={null} git remote add origin https://github.com//.git ``` ```bash theme={null} lua git connect --auto-push ``` This verifies that a GitHub account is linked **and** that `origin` is a GitHub HTTPS remote before enabling auto-push — so you find out about a missing link or remote immediately, not on your next `lua push`. After this, every auto-commit is followed by a push to your GitHub repository. Auto-push is non-blocking: if a push fails (revoked token, network issue, etc.), the commit is kept locally and you can run `git push` manually. ## Commands ### `lua git connect` Enable auto-commits for the current project. | Flag | Description | | ------------- | --------------------------------------------------------------------------------------------------------------- | | `--auto-push` | Also enable auto-push to the linked GitHub remote (requires `lua git auth github` and a GitHub HTTPS `origin`). | ### `lua git disconnect` Disable auto-commits. Existing commits and tags in your repository are left untouched. ### `lua git status` Show the current integration state: whether it's enabled, your git identity, and the most recent Lua-issued commit and tag. ### `lua git auth github` Link a GitHub account using GitHub's OAuth **device flow** (enter a code at [github.com/login/device](https://github.com/login/device)). | Flag | Description | | --------- | ------------------------------------------------------ | | `--force` | Re-link without the "already linked, re-link?" prompt. | ### `lua git auth status` Show the linked GitHub username, granted scopes, and when it was linked. ### `lua git auth disconnect` Remove the locally stored GitHub token. ## Configuration The `git` block in `lua.skill.yaml` is **managed by the CLI** — use the commands above rather than editing it by hand, so the sanity checks always run: ```yaml theme={null} git: enabled: true # set by `lua git connect` autoPush: true # set by `lua git connect --auto-push` ``` ## Troubleshooting The project directory isn't a git repository yet. Run `git init` (and set your `user.name` / `user.email`), then `lua git connect`. Add a GitHub remote: `git remote add origin https://github.com//.git`. Auto-push supports GitHub HTTPS remotes only. Re-link your account with `lua git auth github`. # Integrations Command Source: https://docs.heylua.ai/cli/integrations-command Connect third-party integrations and set up event-driven triggers for your agent via Unified.to ## Overview The `lua integrations` command enables you to connect your agent to third-party services like Linear, Discord, Google Calendar, HubSpot, and 250+ other integrations via [Unified.to](https://unified.to). When you connect an integration: * An **MCP server** is automatically created to expose tools to your agent * **Triggers** can be set up to wake up your agent when events occur in the connected service ```bash theme={null} lua integrations # Interactive mode lua integrations connect # Connect a new integration lua integrations list # List connected integrations lua integrations available # View available integrations lua integrations info # View integration details (scopes, triggers) lua integrations disconnect # Disconnect an integration lua integrations update # Update connection scopes lua integrations webhooks # Manage triggers (webhook subscriptions) lua integrations mcp # Manage MCP servers for connections # Triggers shortcut (alias for lua integrations webhooks) lua integrations triggers # Interactive trigger management lua integrations triggers list # List all triggers lua integrations triggers create # Create a new trigger lua integrations triggers pause # Pause a trigger or all triggers for a connection lua integrations triggers resume # Resume a trigger or all triggers for a connection lua integrations triggers delete # Delete a trigger ``` Top-level `lua triggers` is a different command — it manages [agent triggers](/cli/triggers-command) (paste-anywhere URLs that invoke your agent), not integration triggers. **Limit**: Only 1 connection per integration type is allowed per agent. To change scopes, use `lua integrations update`. ## How It Works 1. **Connect**: Authenticate with a third-party service via OAuth or API token 2. **Auto-MCP**: An MCP server is automatically created and activated for the connection 3. **Agent Access**: Your agent can now use tools from that integration (e.g., create Linear issues, send Discord messages) 4. **Triggers** (optional): Set up event-driven triggers to wake up your agent when things happen (e.g., a task is created, a message is received). Triggers are opt-in — you can skip them at connect and add them later. ## Commands ### lua integrations connect Connect a new third-party integration with optional triggers. ```bash theme={null} # Interactive mode (recommended for first-time setup) lua integrations connect # Non-interactive: connect Linear with OAuth and all scopes lua integrations connect --integration linear --auth-method oauth --scopes all # Connect with triggers enabled (agent wakes up on events) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Connect with all available triggers lua integrations connect --integration linear --auth-method oauth --scopes all --triggers all # Use a custom webhook URL instead of agent trigger lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created --custom-webhook --hook-url https://my-server.com/webhook # Specify specific scopes lua integrations connect --integration linear --auth-method oauth --scopes "task_task_read,task_task_write" # Use API token authentication lua integrations connect --integration linear --auth-method token # Control sensitive data visibility lua integrations connect --integration discord --auth-method oauth --scopes all --hide-sensitive false ``` **What happens:** 1. In interactive mode, you're asked who should own the connection (see below) 2. CLI fetches available integrations 3. You select or specify an integration and auth method 4. Browser opens for OAuth authorization (or you enter API credentials) 5. Connection is established and stored 6. MCP server is automatically created and activated 7. In interactive mode, you're asked whether to configure triggers now (opt-in, default: skip). If triggers are specified via `--triggers`, they are created immediately. #### Who owns the connection — `--scope` | Scope | Who can use it | | ----------------- | ----------------------------------------------------------------------------------- | | `agent` (default) | This agent only. It holds the credential and loses it when the agent goes. | | `user` | You. Every **private** agent you own can use it, including agents you create later. | ```bash theme={null} # Connect as yourself — available on all your private agents lua integrations connect --scope user --integration github --auth-method oauth --scopes all ``` Interactive runs ask and pre-select `agent`. A non-interactive run **without** `--scope` connects to the agent exactly as before, so existing scripts keep their meaning. **Publishing an agent removes its access** to your personal connections. A published, shared or org-visible agent is not one only you can reach, so it is not covered — even if you created it. Connecting as yourself has no wake-up triggers, no account name and no sensitive-data toggle. Passing `--triggers`, `--custom-webhook`, `--hook-url`, `--account-label` or `--hide-sensitive` together with `--scope user` is rejected before anything else happens. Connect at agent scope if you need those, or add triggers later on an agent connection. **Triggers are opt-in**: The interactive flow asks "Do you want to configure triggers now?" and defaults to skip — add only what you actually need. You can always add triggers later with `lua integrations triggers create`. When using `--triggers` non-interactively, triggers are created immediately. ### lua integrations convert Turn a connection an agent owns into a personal one, without reconnecting. ```bash theme={null} lua integrations convert --connection-id abc123 lua integrations convert --connection-id abc123 --force # skip the confirmation ``` The agent you originally connected it to keeps access and its triggers keep firing; every other private agent you own gains it. Only a connection **you** created on a private agent can be converted, and a connection shared with the workspace must stop being shared first. There is no way to convert back, so the command asks for confirmation. Use `--force` only in scripts. ### lua integrations list View all connected integrations and their status. ```bash theme={null} lua integrations list # this agent's connections lua integrations list --scope user # your personal connections lua integrations list --scope all # both ``` **Output:** ``` ============================================================ 🔗 Connected Integrations ============================================================ 🟢 Linear ID: 6978e0294d9c2007ed5cb129 Status: Active MCP Server: linear (✅ active) Connected: 1/27/2026 🟡 Discord ID: 6979e707ea702a223666bfd2 Status: Connected (tools pending) MCP Server: discord (⏸️ inactive) Connected: 1/28/2026 ============================================================ Total: 2 connection(s) ``` **Status Icons:** * 🟢 Active - Connection healthy, tools available * 🟡 Connected (tools pending) - Connection healthy, MCP server inactive * 🔴 Unhealthy - Re-authorization required * ⏸️ Paused - Connection paused ### lua integrations info View detailed information about an integration type, including available OAuth scopes and triggers. This is useful for discovering what's available **before** connecting. ```bash theme={null} # View integration details lua integrations info linear # Output as JSON (for scripting/AI agents) lua integrations info linear --json ``` **Output:** ``` ============================================================ 📋 Integration Info: Linear ============================================================ 🔑 OAuth Scopes: ✓ task_task_read Manage and view issues in Linear ✓ task_task_write Create and update issues in Linear ✓ task_project_read View projects in Linear ... ⚡ Available Triggers: • task_task.created [virtual, 60min interval] A new issue was created in Linear • task_task.updated [virtual, 60min interval] An issue was updated in Linear • task_task.deleted [virtual, 60min interval] An issue was deleted in Linear • task_comment.created [virtual, 60min interval] A new comment was added to an issue ... ============================================================ ``` **Friendly Labels**: Scopes and triggers display user-friendly descriptions (e.g., "A new issue was created in Linear" instead of "task\_task.created") to help non-technical users understand what each option does. ### lua integrations available List all available integrations you can connect. ```bash theme={null} lua integrations available ``` Shows integrations grouped by category (task, messaging, crm, etc.) with auth type indicators: * 🔐 OAuth only * 🔑 API Token only * 🔐🔑 Both available ### lua integrations update Update an existing connection's OAuth scopes. This re-authorizes the connection with new permissions. ```bash theme={null} # Interactive mode lua integrations update # Non-interactive: update Linear with all scopes lua integrations update --integration linear --scopes all # Specific scopes lua integrations update --integration linear --scopes "task_task_read,task_task_write,task_project_read" ``` Update deletes the old connection and creates a new one with updated scopes. You'll need to re-authorize in the browser. ### lua integrations disconnect Remove a connected account. This also deletes the associated MCP server and any webhook subscriptions. ```bash theme={null} # Interactive mode lua integrations disconnect # Non-interactive lua integrations disconnect --connection-id 6978e0294d9c2007ed5cb129 # Disconnect a personal connection — removes it from every agent at once lua integrations disconnect --scope user --connection-id 6978e0294d9c2007ed5cb129 ``` Disconnecting a personal connection asks whether to also forget what it added to memory. The default is **no** — disconnecting and forgetting are separate decisions, and only one of them can't be undone. ### lua integrations webhooks Manage triggers (webhook subscriptions) for connected integrations. Triggers let your agent receive events when things happen in connected services (e.g., task created, message received). List all triggers: ```bash theme={null} lua integrations webhooks list # Output as JSON for scripting lua integrations webhooks list --json ``` **Output:** ``` ──────────────────────────────────────────────────────────────────────────────── ⚡ Triggers / Webhook Subscriptions ──────────────────────────────────────────────────────────────────────────────── 📦 linear ✅ task_task.created (poll, agent trigger) ID: 698304fa49d44978357b6435 ⏸️ task_task.updated (paused by you) (poll, agent trigger) ID: 698304fb49d44978357b6436 💳 task_comment.created (credit-suspended) (poll, agent trigger) ID: 698304fc49d44978357b6437 ──────────────────────────────────────────────────────────────────────────────── Total: 3 trigger(s) ``` **Status Icons:** * ✅ Active — trigger is running * ⏸️ Paused by you — you paused this trigger; resume with `lua integrations triggers resume` * 💳 Credit-suspended — paused due to credit depletion; add credits to re-enable * 🔴 Unhealthy — requires re-authorization * ⚪ Paused externally — paused from outside Lua (e.g., Unified.to dashboard) **JSON Output:** ```json theme={null} { "triggers": [ { "id": "698304fa49d44978357b6435", "integrationType": "linear", "objectType": "task_task", "event": "created", "webhookType": "virtual", "hookUrl": "https://api.heylua.ai/webhook/unifiedto/data", "status": "active", "userPaused": false, "creditSuspended": false, "interval": 60, "connectionId": "698304f949d44978357b6425" } ], "total": 1 } ``` Webhook IDs are displayed for each trigger, making it easy to reference them for pause/resume/delete operations. List available trigger events for an integration: ```bash theme={null} # By integration type (before connecting) lua integrations webhooks events --integration linear # By connection ID (after connecting) lua integrations webhooks events --connection abc123 # As JSON for scripting lua integrations webhooks events --integration linear --json ``` **Output:** ``` ⚡ Available Trigger Events for linear ──────────────────────────────────────────────────────────────────────────────── • task_task.created [virtual] A new issue was created in Linear • task_task.updated [virtual] An issue was updated in Linear • task_task.deleted [virtual] An issue was deleted in Linear • task_comment.created [virtual] A new comment was added to an issue ──────────────────────────────────────────────────────────────────────────────── ``` Create a new trigger: ```bash theme={null} # Interactive mode lua integrations webhooks create # Non-interactive: Agent trigger mode (default - wakes up your agent) lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event created # Custom webhook URL (your own endpoint) lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event created \ --hook-url https://my-server.com/webhook # With custom polling interval for virtual webhooks lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event updated \ --interval 120 ``` **Trigger Modes:** * **Agent Trigger** (default): Events wake up your agent with the payload in `runtimeContext` * **Custom URL**: Events are sent to your specified webhook URL **Webhook Types:** * **Native**: Real-time webhooks from the integration (when supported) * **Virtual**: Polling-based webhooks (Unified.to polls the API at intervals) **Interval options** (for virtual webhooks): * `60` - 1 hour (default) * `120` - 2 hours * `240` - 4 hours * `480` - 8 hours * `720` - 12 hours * `1440` - 24 hours * `2880` - 48 hours Delete a trigger: ```bash theme={null} lua integrations webhooks delete --webhook-id wh_abc123 ``` Pause a single trigger or all triggers for a connection. The connection and MCP server remain active — only event delivery is paused. ```bash theme={null} # Pause a single trigger (interactive: choose from list) lua integrations webhooks pause # Pause a specific trigger by ID lua integrations webhooks pause --webhook-id wh_abc123 # Pause a specific trigger with a reason lua integrations webhooks pause --webhook-id wh_abc123 --reason "Maintenance window" # Pause ALL triggers for a connection lua integrations webhooks pause --connection-id 6978e0294d9c2007ed5cb129 ``` Pausing a trigger is different from deleting it. The subscription is preserved and can be resumed at any time. Credit-suspended triggers cannot be paused/resumed by users — add credits to re-enable them. Resume a paused trigger or all paused triggers for a connection. ```bash theme={null} # Resume a single trigger (interactive: choose from paused list) lua integrations webhooks resume # Resume a specific trigger by ID lua integrations webhooks resume --webhook-id wh_abc123 # Resume ALL paused triggers for a connection lua integrations webhooks resume --connection-id 6978e0294d9c2007ed5cb129 ``` Credit-suspended triggers are skipped during resume — they require credits to be added to your account first. ### lua integrations mcp Manage MCP servers for connections. MCP servers are automatically created when you connect an integration, but you can activate/deactivate them manually. List connections with MCP server status: ```bash theme={null} lua integrations mcp list ``` **Output:** ``` ──────────────────────────────────────────────────────────────────────────────── 🔌 MCP Servers for Connections ──────────────────────────────────────────────────────────────────────────────── Connection: 6978e0294d9c2007ed5cb129 Integration: Linear MCP Server: linear Status: ✅ active ──────────────────────────────────────────────────────────────────────────────── ``` Activate MCP server for a connection: ```bash theme={null} lua integrations mcp activate --connection 6978e0294d9c2007ed5cb129 ``` Once activated, your agent can use tools from this integration. Deactivate MCP server for a connection: ```bash theme={null} lua integrations mcp deactivate --connection 6978e0294d9c2007ed5cb129 ``` The connection remains but tools are hidden from the agent. ## Non-Interactive Mode All integrations commands support non-interactive mode for CI/CD, automation, and AI coding assistants. ### Discovery Commands Use these to discover available options before connecting: ```bash theme={null} # List available integrations lua integrations available # Get integration details (scopes and triggers) lua integrations info linear lua integrations info linear --json # List available trigger events lua integrations webhooks events --integration linear lua integrations webhooks events --integration linear --json ``` ### Connect Options | Option | Description | | ------------------------- | ------------------------------------------------------------------------------- | | `--integration ` | Integration type (e.g., `linear`, `discord`, `googlecalendar`) | | `--auth-method ` | Authentication method: `oauth` or `token` | | `--scopes ` | Comma-separated OAuth scopes, or `all` for all available | | `--hide-sensitive ` | Hide sensitive data from MCP tools (default: `true`) | | `--triggers ` | Comma-separated triggers (e.g., `task_task.created,task_task.updated`) or `all` | | `--custom-webhook` | Use custom webhook URL instead of agent trigger | | `--hook-url ` | Custom webhook URL (use with `--custom-webhook`) | ### Info Options | Option | Description | | -------- | ---------------------------- | | `--json` | Output as JSON for scripting | ### Disconnect Options | Option | Description | | ---------------------- | --------------------------- | | `--connection-id ` | Connection ID to disconnect | ### Trigger/Webhook Options | Option | Description | | ---------------------- | ------------------------------------------------- | | `--connection ` | Connection ID for the trigger | | `--connection-id ` | Connection ID for pause/resume all triggers | | `--integration ` | Integration type (for events discovery) | | `--object ` | Object type (e.g., `task_task`, `calendar_event`) | | `--event ` | Event type: `created`, `updated`, or `deleted` | | `--hook-url ` | Custom webhook URL (default: agent trigger) | | `--interval ` | Polling interval for virtual webhooks | | `--webhook-id ` | Trigger ID (for delete/pause/resume actions) | | `--reason ` | Optional reason for pausing (informational) | | `--json` | Output as JSON (for list/events commands) | ### MCP Options | Option | Description | | ------------------- | -------------------------------- | | `--connection ` | Connection ID for MCP operations | ### Examples ```bash theme={null} # Discover available integrations and their details lua integrations available lua integrations info linear --json # Connect (interactive — triggers are opt-in, default: skip) lua integrations connect --integration linear --auth-method oauth --scopes all # Connect with specific triggers in one command (non-interactive) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Add a trigger after connection (via alias) lua integrations triggers create \ --connection 6978e0294d9c2007ed5cb129 \ --object task_task \ --event deleted # Create trigger with custom webhook URL lua integrations webhooks create \ --connection 6978e0294d9c2007ed5cb129 \ --object task_task \ --event created \ --hook-url https://my-server.com/webhook # Pause / resume a specific trigger lua integrations triggers pause --webhook-id 698304fa49d44978357b6435 lua integrations triggers resume --webhook-id 698304fa49d44978357b6435 # Pause / resume ALL triggers for a connection lua integrations triggers pause --connection-id 6978e0294d9c2007ed5cb129 lua integrations triggers resume --connection-id 6978e0294d9c2007ed5cb129 # List triggers with status icons lua integrations triggers list lua integrations triggers list --json # Disconnect lua integrations disconnect --connection-id 6978e0294d9c2007ed5cb129 ``` ## Workflow Example ```bash theme={null} # See what integrations are available lua integrations available # Get details about a specific integration lua integrations info linear ``` View available integrations, their OAuth scopes, and trigger events. ```bash theme={null} # Interactive — you'll be asked whether to add triggers now (opt-in, default: skip) lua integrations connect --integration linear --auth-method oauth --scopes all # Or specify triggers directly (non-interactive) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated ``` Complete OAuth in the browser. MCP server is auto-created and activated. Add triggers now or skip and add them later with `lua integrations triggers create`. ```bash theme={null} lua integrations list ``` Confirm the connection shows as Active (🟢) and triggers are configured. ```bash theme={null} lua chat -e sandbox -m "Create a Linear issue titled 'Test from Lua'" ``` Your agent should now have access to Linear tools. ```bash theme={null} # List all triggers (with status icons) lua integrations triggers list # Add more triggers post-connect lua integrations triggers create --connection --object task_task --event deleted # Pause a trigger temporarily lua integrations triggers pause --webhook-id # Resume it later lua integrations triggers resume --webhook-id ``` When events occur in Linear, your agent will be notified automatically. Pause/resume any time without losing the subscription. ## Event-Driven Triggers Triggers are a powerful way to make your agent **reactive** to external events. Instead of polling or manually checking for updates, your agent automatically wakes up when something happens. ### How Triggers Work 1. **You connect an integration** with triggers enabled 2. **Unified.to monitors** the connected service for events 3. **When an event occurs** (e.g., a task is created), Unified.to sends a webhook 4. **Your agent wakes up** with the event data in `runtimeContext` 5. **The agent can respond** based on what happened ### Example: React to New Linear Issues ```bash theme={null} # Connect Linear with trigger for new issues lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created ``` When a new issue is created in Linear, your agent receives the event data and can: * Send a notification to Slack * Update a dashboard * Assign the issue to a team member * Any other action your agent is configured to perform ### Trigger Types | Type | Description | | ----------- | -------------------------------------------------------------- | | **Native** | Real-time webhooks (when the integration supports them) | | **Virtual** | Polling-based (Unified.to checks at intervals, default 1 hour) | Most integrations use virtual webhooks. The polling interval can be configured: ```bash theme={null} # Set a 2-hour polling interval lua integrations webhooks create --connection abc123 --object task_task --event created --interval 120 ``` ## Authentication Types ### OAuth 2.0 (Recommended) * Secure authorization flow via browser * Scope selection for fine-grained permissions * Automatic token refresh * Requires OAuth to be configured for the integration in your Unified.to workspace ### API Token / Personal Access Token * Direct credential entry * Simpler setup * Token fields vary by integration (e.g., API Key, Personal Access Token, Bot Token) * Instructions shown during connection for where to obtain credentials ## Tips Triggers make your agent reactive to external events. They are opt-in — add only what you actually need: ```bash theme={null} # Add triggers after connecting (recommended: start with what you need) lua integrations triggers create --connection abc123 --object task_task --event created # Or specify triggers at connect time (non-interactive) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Pause a trigger temporarily (preserves subscription) lua integrations triggers pause --webhook-id lua integrations triggers resume --webhook-id ``` Your agent automatically wakes up when events occur — no polling required! Use discovery commands to understand what's available: ```bash theme={null} # See all integrations lua integrations available # Get detailed info about an integration lua integrations info linear # See available trigger events lua integrations webhooks events --integration linear ``` This is especially useful for AI coding assistants building agents programmatically. By default, `--hide-sensitive true` is enabled, which hides sensitive fields from MCP tool responses. Disable only if your agent needs access to sensitive data: ```bash theme={null} lua integrations connect --integration discord --auth-method oauth --scopes all --hide-sensitive false ``` When using OAuth, select only the scopes your agent needs. Use `all` during development, then restrict to specific scopes in production: ```bash theme={null} # Development lua integrations connect --integration linear --auth-method oauth --scopes all # Production lua integrations connect --integration linear --auth-method oauth --scopes "task_task_read,task_task_write" ``` Each agent can have only one connection per integration type. To change accounts or scopes, use `lua integrations update` or disconnect and reconnect. Use `--json` for machine-readable output: ```bash theme={null} lua integrations info linear --json lua integrations webhooks events --integration linear --json ``` Perfect for CI/CD pipelines and AI coding assistants. ## Related Commands Manage all MCP servers (including non-integration servers) Test your agent with integration tools ## See Also * [MCP Servers Overview](/overview/mcp-servers) - Understanding MCP servers * [Webhooks Overview](/overview/webhooks) - Creating webhook handlers * [LuaWebhook API](/api/luawebhook) - Webhook API reference # Jobs Command Source: https://docs.heylua.ai/cli/jobs-command Manage scheduled jobs from the CLI — view, deploy, activate, trigger, and inspect execution history ## Overview `lua jobs` manages scheduled jobs defined with `LuaJob`. It mirrors the surfaces available for skills (view, versions, deploy, activate, deactivate, delete) and adds two job-specific actions: `trigger` (manual fire) and `history` (execution log). ```bash theme={null} lua jobs # Interactive management lua jobs view # List all jobs lua jobs versions -i myJob # List versions for a job lua jobs trigger -i healthCheck # Fire a job manually lua jobs history -i myJob # View execution history ``` For defining jobs in code, see [LuaJob API](/api/luajob). For creating jobs dynamically from inside a tool at runtime, see [Jobs API](/api/jobs). ## Subcommands | Action | What it does | | ------------ | --------------------------------------------------------------------------------- | | `view` | List all jobs defined on the agent. | | `versions` | List every version of a specific job. | | `deploy` | Promote a version to active. | | `activate` | Re-enable a deactivated job. | | `deactivate` | Pause scheduled execution. Active version retained — re-activate with `activate`. | | `trigger` | Manually fire a job once (does not affect schedule). | | `history` | Print recent execution history with status and timestamps. | | `delete` | Permanently remove a job and all its versions. | ## Options | Option | Description | | ------------------------- | --------------------------------------------------------- | | `-i, --job-name ` | Job name. Required for most non-interactive actions. | | `-v, --job-version ` | Version for `deploy`. Pass `latest` to deploy the newest. | ## Examples ```bash theme={null} # Interactive — pick action and target from menus lua jobs # List everything lua jobs view # Promote a specific version to active lua jobs deploy -i dailyReport -v 1.0.3 # Promote the latest version lua jobs deploy -i dailyReport -v latest # Pause and resume lua jobs deactivate -i dailyReport lua jobs activate -i dailyReport # Manually trigger a job (e.g. for ad-hoc runs) lua jobs trigger -i healthCheck # Inspect recent runs lua jobs history -i dailyReport # Delete a retired job lua jobs delete -i oldCleanupJob ``` ## Triggering vs Deploying | Command | When to use | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `lua jobs trigger -i ` | Run the job **once** right now. Useful for ad-hoc runs, smoke-tests after a deploy, and debugging. Schedule is untouched. | | `lua jobs deploy -i -v ` | Promote a different version to active. Used after `lua push job` lands a new version on the server. | ## Common Workflow After editing a job in code: ```bash theme={null} lua push job # Build + upload a new version lua jobs versions -i myJob # Confirm the new version is on the server lua jobs deploy -i myJob -v latest # Promote it lua jobs trigger -i myJob # Smoke-test the new version lua jobs history -i myJob # Verify it ran cleanly ``` ## Logs and Errors To debug job execution, pair this with [`lua logs`](/cli/logs-command): ```bash theme={null} lua logs --type job --name myJob --limit 20 lua logs --type job --name myJob --limit 20 --json | jq ``` ## Related * [LuaJob API](/api/luajob) — define a job in code * [Jobs API](/api/jobs) — create jobs dynamically at runtime * [Logs Command](/cli/logs-command) * [Push & Deploy](/cli/skill-management#lua-push) * [Version Command](/cli/version-command) — snapshot and promote every primitive, including jobs, together # Logs Command Source: https://docs.heylua.ai/cli/logs-command View and debug agent execution logs ## Overview The `lua logs` command provides an interactive interface for viewing and navigating your agent's execution logs with powerful filtering capabilities. ```bash theme={null} lua logs ``` **Looking for the canonical debug loop?** See [Debugging your agent](/cli/debugging) for the recommended push → test → check flow that uses `lua logs` as the truth source. ## When to run `lua logs` There are three moments when `lua logs` is the most valuable command in the CLI: **1. Immediately after a `lua chat -m "test"`.** A chat response can look fine while the server quietly logged a billing failure, a tool exception, or an LLM provider error. Run `lua logs --type agent_error --limit 5` after every test message — if the count is zero, the test passed cleanly. The CLI also runs a quiet `agent_error` probe automatically after each chat turn and prints a one-line warning when new errors fire (set `LUA_NO_HINTS=1` to silence it). **2. After a `lua push` to verify a new version is healthy in production.** The CLI prints a `✨ Tip:` line at the end of every push pointing at the right `lua logs --type X --limit 10` command for what you just deployed — follow it. **3. When investigating a user-reported issue.** Filter to the affected user with `--user-id ` and to the most likely component type: ```bash theme={null} lua logs --type all --user-id user_abc123 --limit 50 lua logs --type skill --name --user-id user_abc123 --limit 20 ``` ### Non-Interactive Mode ```bash theme={null} # View all logs lua logs --type all --limit 50 # Filter by type lua logs --type skill --limit 20 lua logs --type webhook --limit 20 lua logs --type job --limit 20 # Filter by specific entity lua logs --type skill --name mySkill --limit 10 # Pagination lua logs --type all --limit 20 --page 2 # JSON output for scripting lua logs --type all --json # View logs for a different agent (admin access required) lua logs --agent-id agent-abc123 lua logs --agent-id agent-abc123 --type skill --limit 10 # Voice calls lua logs --type calls # recent voice calls (table) lua logs --type calls --status failed --json # failed calls as JSON ``` | Option | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--type ` | Filter: `all`, `skill`, `job`, `webhook`, `preprocessor`, `postprocessor`, `device`, `device-trigger`, `user_message`, `agent_response`, `agent_error`, `mcp`, `rag`, `runtime`, `calls` | | `--name ` | Entity name (requires --type, not for message types) | | `--user-id ` | Filter logs by user ID | | `--agent-id ` | View logs for a specific agent (overrides the agent in `lua.config.yaml`). Requires admin access to the target agent. | | `--status ` | Filter calls by status (`completed`, `failed`, `in-progress`, etc.). Only with `--type calls`. | | `--limit ` | Number of logs (default: 20) | | `--page ` | Page number for pagination | | `--json` | Output as JSON for scripting | **`--agent-id`** is useful for inspecting logs across multiple agents you own without switching project directories. If you don't have admin access to the requested agent, the command returns an access-denied error. **Type values:** Use `runtime` for platform runtime logs (was previously `mastra`). Use `rag` for knowledge base search logs, `device-trigger` for device trigger execution logs, and `calls` for voice call records. **`agent_error` vs `runtime`:** Use `--type agent_error` to find pipeline failures (billing, validation, LLM provider errors) — these often don't surface in the chat response itself. Use `--type runtime` for agent runtime / LLM SDK / framework-level diagnostics (low-level model and orchestration logs). ## Post-deploy verification recipe ```bash theme={null} # After deploying — generate traffic FIRST lua push all --force --auto-deploy lua chat -e production -m "your test message" # ← generate traffic first lua logs --type skill --limit 10 # ← now logs exist ``` Verify pipeline errors: ```bash theme={null} lua chat -e production -m "verify deploy" -t prod-verify --clear lua logs --type agent_error --limit 5 ``` `lua logs` returns nothing immediately after deploy. You must generate traffic (via `lua chat`, a webhook call, or a job trigger) before execution logs appear. If `agent_error --limit 5` returns no entries timestamped after your deploy, the version is healthy. If it does, read the entries before walking away. See [Debugging your agent](/cli/debugging) for the complete flow. Filter logs by Skills, Jobs, Webhooks, Preprocessors, or Postprocessors View logs for all your components, including dynamically created jobs See which component generated each log, including names and IDs Easily spot errors, warnings, and different log types ## Log Types **Execution errors and failures** * Tool execution failures * API errors * Invalid configurations * Stack traces for debugging Color: Red **Warnings and potential issues** * Performance warnings * Deprecated features * Configuration warnings Color: Yellow **Debug information** * Tool inputs/outputs * Function execution status * Detailed operation info Color: Blue **General information** * Operation status * Configuration changes * General messages Color: Cyan **Operation metrics** * Operation started * Operation completed * Duration measurements Color: Green ## Interactive Flow ``` 📊 Viewing logs for agent: myAgent ? What logs do you want to view? ❯ 📋 All agent logs 🔎 Filter logs ``` ``` ? Filter by: ❯ Skills Jobs Webhooks Preprocessors Postprocessors MCP RAG (knowledge base) Runtime Agent errors Device trigger ────────────────────── 📋 All logs ← Back ``` ``` ? Select a job: ❯ All Jobs ────────────────────── Nightly User Report Daily Cleanup Task ────────────────────── ← Back ``` **All Jobs Visible**: All your jobs appear in the list, including ones created dynamically in your code. ``` All Agent Logs ──────────────────────────────────────────────────── Page 1 of 88 (872 total logs) ❌ [2/3/2025, 4:40:55 PM] ERROR Job Name: Nightly User Report Job ID: job_abc123 Error executing function: Invalid API Key ────────────────────────────────────────────────── ✅ [2/3/2025, 4:40:55 PM] COMPLETE Skill Name: customer-service Skill ID: skill_def456 Tool Name: search_products Duration: 125ms Execute function completed ────────────────────────────────────────────────── ``` ``` ? Navigation: ❯ Next Page → ← Previous Page 🔢 Go to specific page 🔄 Refresh ❌ Exit ``` ## Use Cases ### Debug Tool Errors ```bash theme={null} $ lua logs → Filter logs → Skills → Select your skill → Look for ❌ ERROR entries → Review error messages → Fix issues in your code ``` ### Monitor Performance ```bash theme={null} $ lua logs → Filter logs → Select component type → Find ✅ COMPLETE entries → Check duration metrics → Identify slow operations ``` ### Verify Tool Execution ```bash theme={null} $ lua logs → Filter logs → Skills → Select your skill → Look for 🔍 DEBUG entries → Verify tool inputs/outputs ``` ### Track Webhook Activity ```bash theme={null} $ lua logs → Filter logs → Webhooks → Select your webhook → Review webhook execution logs → Check request/response data → Debug external integrations ``` ### Monitor Job Execution ```bash theme={null} $ lua logs → Filter logs → Jobs → Select your job → View scheduled job execution logs → Check for errors or performance issues ``` ## Example Session ```bash theme={null} $ lua logs ✅ Authenticated 📊 Viewing logs for agent: myAgent ? What logs do you want to view? 🔎 Filter logs ? Filter by: Jobs ? Select a job: Nightly User Report All Agent Logs ──────────────────────────────────────────────────────────────────────────────── Page 1 of 12 (120 total logs) ▶️ [11/7/2025, 10:30:00 AM] START Job Name: Nightly User Report Job ID: job_abc123 Duration: 150ms Starting job execution... ────────────────────────────────────────────────────────────────────────────── ✅ [11/7/2025, 10:30:05 AM] COMPLETE Job Name: Nightly User Report Job ID: job_abc123 Duration: 5234ms Job execution completed successfully ────────────────────────────────────────────────────────────────────────────── ? Navigation: Next Page → [Shows page 2...] ``` ### Filtering by Skill ```bash theme={null} $ lua logs ? What logs do you want to view? 🔎 Filter logs ? Filter by: Skills ? Select a skill: customer-service All Agent Logs ──────────────────────────────────────────────────────────────────────────────── Page 1 of 25 (250 total logs) ℹ️ [11/7/2025, 10:31:15 AM] INFO Skill Name: customer-service Skill ID: skill_def456 Tool Name: search_products Fetching products matching query... ────────────────────────────────────────────────────────────────────────────── ✅ [11/7/2025, 10:31:16 AM] COMPLETE Skill Name: customer-service Skill ID: skill_def456 Tool Name: search_products Duration: 89ms Found 15 products matching "laptop" ────────────────────────────────────────────────────────────────────────────── ``` ## Log Entry Details Each log entry shows you: ### For Skill Logs * **Skill Name** - Which skill generated the log * **Skill ID** - Unique identifier for the skill * **Tool Name** - Which tool was running (if applicable) * **Timestamp** - When the log was created * **Log Type** - Error, debug, info, warn, start, or complete * **Message** - The actual log message * **Duration** - How long the operation took (for completed operations) ### For Job Logs * **Job Name** - Which job generated the log * **Job ID** - Unique identifier for the job * **Timestamp** - When the log was created * **Log Type** - Error, debug, info, warn, start, or complete * **Message** - The actual log message * **Duration** - How long the job took to run ### For Webhook, Preprocessor, and Postprocessor Logs * **Name** - Which component generated the log * **ID** - Unique identifier * **Timestamp** - When the log was created * **Log Type** - Error, debug, info, warn, start, or complete * **Message** - The actual log message * **Duration** - How long the operation took **Tool Information**: Tool names are only shown for skill logs, since tools belong to skills. ## Navigation Options View the next page of logs Disabled if on last page Go back to previous page Disabled if on first page ``` ? Enter page number (1-88): 45 ``` Jump directly to any page Reload current page with latest logs Useful for monitoring in real-time Close the logs viewer ## Best Practices ```bash theme={null} # Get overview first lua logs → All agent logs # Then filter if needed lua logs → Specific skill logs ``` ```bash theme={null} # After deploying — generate traffic FIRST lua push all --force --auto-deploy lua chat -e production -m "your test message" # ← generate traffic first lua logs --type skill --limit 10 # ← now logs exist lua logs → All agent logs → Refresh periodically ``` ```bash theme={null} # Issue with specific skill lua logs → Specific skill logs → Select problem skill # Focus on errors # Look for ❌ ERROR entries ``` ```bash theme={null} # Check operation speed lua logs → All agent logs # Note duration on ✅ COMPLETE entries # Identify slow operations (>1000ms) ``` ## Troubleshooting **Causes:** * Skills not deployed yet * Agent hasn't been used * Viewing wrong agent **Solution:** ```bash theme={null} lua push # Deploy skills lua chat # Use agent to generate logs lua logs # View logs ``` **Try:** * Navigate through pages using the navigation menu * Filter by the specific component (skill, job, etc.) * Check the most recent pages first (page 1) * Look at the timestamp to find when the error occurred **Use refresh:** ``` ? Navigation: 🔄 Refresh ``` Reloads current page with latest data ## Integration with Workflow ### During Development ```bash theme={null} # Make changes vim src/tools/MyTool.ts # Test lua chat # Check logs immediately lua logs # Look for errors or warnings ``` ### After Deployment ```bash theme={null} # Deploy lua push && lua deploy # Monitor logs lua logs # Watch for errors in production # Check performance metrics ``` ### Debugging Issues ```bash theme={null} # User reports an issue lua logs # Find the relevant error in the logs # Note the timestamp and which component had the error # Fix the issue in your code # Deploy the fix # Monitor logs again to verify the fix ``` ## Related Commands Canonical push → test → check flow Test agent (generates logs + auto agent\_error probe) Test tools locally (no remote logs) Deploy skills (required for logs) ## Next Steps Generate logs to view Push skills to generate logs Common issues and solutions Monitor production environment # Marketplace Command Source: https://docs.heylua.ai/cli/marketplace-command Browse, publish, and install marketplace skills and agent templates ## Overview `lua marketplace` is the CLI surface for the Lua Marketplace. It has one shape: ```bash theme={null} lua marketplace [noun] [action] [options] ``` `noun` is `skill` or `template`. Everything about a *skill* — a single reusable tool package — lives under `lua marketplace skill`. Everything about an *agent template* — a full, versioned agent manifest you can install or roll out across a fleet — lives under `lua marketplace template`. ```bash theme={null} lua marketplace # Interactive: pick a domain (Skills / Agent templates) lua marketplace skill # Skill action menu lua marketplace template # Template action menu ``` Not sure which one you need? See [Marketplace Overview](/marketplace/overview#skills-vs-agent-templates) for when to publish a skill versus a template. This replaces the older `lua marketplace create` / `lua marketplace install` role split. If you have scripts or muscle memory built on those, see [Migrating from the old command shape](#migrating-from-the-old-command-shape) below. ## `lua marketplace skill` All skill-marketplace actions live in one flat namespace — publishing your own skills and installing others' both go through `lua marketplace skill `. ### Actions | Action | What it does | | ----------- | ------------------------------------------------------------------------- | | `list` | Create a marketplace listing for one of your skills. | | `publish` | Publish a specific version of a listed skill. | | `edit` | Update listing metadata (display name). | | `unlist` | Hide the listing — no new installs, but existing installers keep working. | | `unpublish` | Remove a specific published version. | | `mine` | View the skills you've listed. | | `search` | Search the marketplace by free-text query. | | `view` | Show the detail page for a specific listing. | | `install` | Install a specific version of a skill into your agent. | | `update` | Update an installed skill (e.g. to a newer version, or its env vars). | | `uninstall` | Remove an installed skill. | | `installed` | List skills installed on this agent. | `mine` and `edit` are the renamed publishing-side actions — they used to be called `view` and `update` under the old `lua marketplace create` role, but those names now belong to the installer-side actions (`view` looks up a listing by id, `update` changes an installed skill). All twelve actions support `--json`. ### Options | Option | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `--skill-name ` | Skill name (for `list`, `update`, `uninstall`). | | `--display-name ` | Display name shown in the marketplace (for `list`/`edit`). | | `--visibility ` | Who can see and install the listing (for `list`). Defaults to `public`. | | `--marketplace-id ` | Marketplace skill ID (returned after `list`). | | `--version-id ` | Version ID (for `publish`/`install`/`update`). | | `--changelog ` | Changelog text shown to installers (for `publish`). | | `--env-vars-json ` | JSON array describing required env vars (for `publish`), e.g. `[{"name":"STRIPE_KEY","required":true,"description":"..."}]`. | | `--query ` | Search query (for `search`). | | `--page ` | Page number for paginated search. | | `--limit ` | Results per page. | | `--env-vars ` | Comma-separated `key=value` pairs (for `install`/`update`). | | `--force` | Skip confirmation prompts. | | `--json` | Output as JSON. | ### Examples ```bash theme={null} # Interactive skill menu lua marketplace skill # View my listed skills lua marketplace skill mine lua marketplace skill mine --json | jq # List a skill — public by default lua marketplace skill list \ --skill-name mySkill \ --display-name "My Skill" # List a private skill — only your org can find and install it lua marketplace skill list \ --skill-name internalCrmSkill \ --display-name "Internal CRM Helper" \ --visibility private # Publish a specific version with a changelog lua marketplace skill publish \ --marketplace-id mkt_xyz \ --version-id v1 \ --changelog "Adds support for European VAT" # Edit display name lua marketplace skill edit \ --marketplace-id mkt_xyz \ --display-name "My Skill (Pro)" # Unlist (existing installers unaffected) lua marketplace skill unlist --marketplace-id mkt_xyz --force # Unpublish a specific version lua marketplace skill unpublish \ --marketplace-id mkt_xyz \ --version-id v1 \ --force # Search the marketplace lua marketplace skill search --query "CRM" lua marketplace skill search --query "CRM" --page 2 --limit 20 # View a specific listing lua marketplace skill view --marketplace-id mkt_xyz lua marketplace skill view --marketplace-id mkt_xyz --json # Install a specific version lua marketplace skill install \ --marketplace-id mkt_xyz \ --version-id v1 \ --force # Install with env vars lua marketplace skill install \ --marketplace-id mkt_xyz \ --version-id v1 \ --env-vars "STRIPE_KEY=sk_xxx,WEBHOOK_SECRET=whsec_yyy" \ --force # List installed skills lua marketplace skill installed lua marketplace skill installed --json # Update an installed skill to a newer version lua marketplace skill update \ --skill-name myCRMSkill \ --version-id v2 # Update env vars on an installed skill lua marketplace skill update \ --skill-name myCRMSkill \ --env-vars "STRIPE_KEY=sk_new" # Uninstall lua marketplace skill uninstall --skill-name myCRMSkill --force ``` See [Publishing Skills](/marketplace/creator-guide) and [Installing Skills](/marketplace/installer-guide) for the full lifecycle, including private visibility. ## `lua marketplace template` Agent templates are published, versioned snapshots of an agent's full deployable manifest — skills, webhooks, jobs, processors, triggers, and model — plus the authored declarations that drive the deploy experience: connections, a persona template, trigger presets, and parameter metadata. See [Agent Templates](/marketplace/agent-templates) for the concepts and [Publishing Templates](/marketplace/publishing-templates) for the creator workflow; this section is the flag reference. ### Actions | Action | What it does | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create` | Create a new template from the current agent. | | `draft` | Compose the `template:` section of `lua.skill.yaml` — the server infers connections, persona variables, trigger presets, and parameter metadata from your agent for you to review and edit before publishing. | | `publish` | Freeze a new template version from a promoted agent version, together with the authored `template:` sections. | | `view` | Show a template's detail page, or a specific version's manifest. | | `versions` | List all published versions of a template. | | `install` | Install a template's primitives onto the current agent. | | `apply` | Push a template version out to a fleet of agents that have it installed. | | `status` | Show the fleet install ledger — which agent runs which version. | | `installed` | List templates installed on the current agent. | | `uninstall` | Remove a template's managed primitives from the current agent. | ### Options | Option | Description | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--name ` | Internal template identifier (for `create`). | | `--display-name ` | Display name (for `create`). | | `--description ` | Description (for `create`). | | `--visibility ` | Who can see and install the template (for `create`). Defaults to `private`. | | `--template-id ` | Template ID. | | `--source-version ` | Agent version to compose or freeze from (for `draft`/`publish`). Defaults to the agent's active version. | | `--changelog ` | Changelog for this template version (for `publish`). | | `--env-contract ` | Declare an env-contract entry; repeatable. Use `KEY?=description` for an optional variable (for `publish`). Omitted entirely, the new version inherits the previous version's contract. | | `--version ` | A specific template version (for `view`/`install`/`apply`). Defaults to the latest published version. | | `--env-vars ` | Comma-separated `key=value` pairs to satisfy the env contract (for `install`). | | `--allow-creator-updates` | Let this template's creator push future `apply` updates onto your agent (for `install`). Off by default. | | `--skip-env-check` | **Retired.** Still accepted so existing scripts don't break, but it no longer skips anything — the CLI prints a deprecation warning and the env-contract check is enforced server-side regardless. Satisfy the contract with [`lua env`](/cli/env-command) or `--env-vars` instead. | | `--agents ` | Comma-separated target agent IDs (for `apply`). | | `--file ` | Path to a file with one target agent ID per line (for `apply`). | | `--all-installed` | Target every agent that already has this template installed (for `apply`). | | `--no-wait` | Print the apply run ID immediately instead of polling for completion (for `apply`). | | `--yes` | Auto-confirm **only** the publish consequence prompt shown when a publish clears or narrows authored `template:` sections (for `publish`). Unlike `--force`, it skips no other confirmation. | | `--force` | Skip confirmation prompts. For `draft`, it changes the merge behavior instead: the composed draft **replaces** your authored `template:` sections wholesale rather than merging additively. | | `--json` | Output as JSON. | ### The `template:` section of `lua.skill.yaml` `draft` writes (and `publish` reads) a `template:` section in your project's `lua.skill.yaml` carrying the four authored declarations: `connections`, `personaTemplate`, `triggerPresets`, and `paramsMeta`. Two rules govern how it publishes: * **All four sections always ship together.** Whenever a `template:` section exists, `publish` serializes all four sections — a section that's absent or empty is an **explicit clear** of that section, never "inherit the previous version's". (A project with no `template:` section at all sends nothing template-shaped, and the authored sections inherit from the previous version as before.) * **Clearing or narrowing requires a confirm.** If the publish would clear a section, or remove keys, relative to the latest published version, the CLI prints a per-section consequence diff — e.g. clearing `personaTemplate` means *new installs get no persona template; installed personas are untouched* — and asks you to confirm. Pass `--yes` to auto-confirm exactly this prompt in scripts; in CI or non-interactive runs the publish proceeds with the diff printed to stdout. Re-running `draft` never destroys your edits: it merges additively (new inferred entries are appended, your existing keys are never modified) and prints a per-section added/kept diff. Use `draft --force` only when you want the composed draft to replace your authored sections wholesale. ### Examples ```bash theme={null} # Interactive template menu lua marketplace template # Create a template from the current agent — private by default lua marketplace template create \ --name support-bot \ --display-name "Support Bot" # Compose the template: section of lua.skill.yaml (additive merge; review, then publish) lua marketplace template draft --template-id tpl_abc # Recompose from a specific promoted agent version, replacing authored sections wholesale lua marketplace template draft --template-id tpl_abc --source-version 12 --force # Publish a version, declaring its env contract lua marketplace template publish \ --template-id tpl_abc \ --changelog "Add refund skill" \ --env-contract "STRIPE_KEY=Stripe secret key" \ --env-contract "SUPPORT_EMAIL?=Fallback contact address" # Publish in a script, auto-confirming only the section clear/narrow prompt lua marketplace template publish --template-id tpl_abc --yes # Inspect a template, or a specific version's manifest lua marketplace template view --template-id tpl_abc lua marketplace template view --template-id tpl_abc --version 2 --json # List all published versions lua marketplace template versions --template-id tpl_abc # Install onto the current agent lua marketplace template install \ --template-id tpl_abc \ --env-vars "STRIPE_KEY=sk_live_xxx" \ --force # Roll out a new version to a specific set of agents lua marketplace template apply \ --template-id tpl_abc \ --agents agent_1,agent_2,agent_3 \ --force # Roll out to every agent that already has it installed lua marketplace template apply --template-id tpl_abc --all-installed --force # Check the fleet ledger lua marketplace template status --template-id tpl_abc # List templates installed on this agent lua marketplace template installed # Remove a template's primitives from this agent lua marketplace template uninstall --template-id tpl_abc --force ``` ### End to end: from project to installable template The whole creator flow in one block, using a small standup-tracking agent (one skill that logs wins, one scheduled nudge job, a persona): ```bash theme={null} # Build and ship the source agent lua init # new project (or use an existing one) lua compile # build the primitives lua push all --force # push them to the platform lua version promote 1 # promote the version templates will freeze # Turn it into a template lua marketplace template create \ --name standup-sidekick \ --display-name "Standup Sidekick" # → prints the template ID lua marketplace template draft \ --template-id tpl_standup # writes the template: section # of lua.skill.yaml for review # Edit lua.skill.yaml: polish display names, required flags, persona {{vars}} … lua marketplace template publish \ --template-id tpl_standup \ --changelog "v1: win logging + standup nudge" # Install it onto another agent (run from that agent's project) lua marketplace template install --template-id tpl_standup --force ``` For the full lifecycle around these commands — what `draft` infers and how to review it, and what installers experience — see [Publishing Templates](/marketplace/publishing-templates) and [Deploying Templates](/marketplace/deploying-templates). ## Migrating from the old command shape The former `lua marketplace [create|install] [action]` role split is gone. Every invocation maps onto the new `skill` noun: | Old | New | | ------------------------------------------------------ | ------------------------------------- | | `lua marketplace create list ...` | `lua marketplace skill list ...` | | `lua marketplace create publish ...` | `lua marketplace skill publish ...` | | `lua marketplace create update ...` (metadata) | `lua marketplace skill edit ...` | | `lua marketplace create unlist ...` | `lua marketplace skill unlist ...` | | `lua marketplace create unpublish ...` | `lua marketplace skill unpublish ...` | | `lua marketplace create view ...` (my listings) | `lua marketplace skill mine ...` | | `lua marketplace install search ...` | `lua marketplace skill search ...` | | `lua marketplace install view ...` | `lua marketplace skill view ...` | | `lua marketplace install install ...` | `lua marketplace skill install ...` | | `lua marketplace install update ...` (installed skill) | `lua marketplace skill update ...` | | `lua marketplace install uninstall ...` | `lua marketplace skill uninstall ...` | | `lua marketplace install installed ...` | `lua marketplace skill installed ...` | All flags keep their old names and meaning — only the noun/action path changed. Agent templates (`lua marketplace template ...`) are new; there's no old shape to migrate from. ## Env Var Formats When a skill declares required env vars, two flag shapes are used: | Flag | Purpose | Format | | ----------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------- | | `--env-vars-json` | **Publishing** — declare what your skill needs. | JSON array of objects `[{"name":"STRIPE_KEY","required":true,"description":"..."}]` | | `--env-vars` | **Installing** — provide values when installing. | Comma-separated `key=value` pairs | Agent templates use a single `--env-contract KEY=description` flag (repeatable, `KEY?=description` for optional) at publish time, and the same `--env-vars k=v,...` shape at install time. The contract is enforced server-side — a missing required variable blocks the install with a list of what's missing. See [Agent Templates: the env contract](/marketplace/agent-templates#the-env-contract). ## Related * [Marketplace Overview](/marketplace/overview) * [Publishing Skills](/marketplace/creator-guide) * [Installing Skills](/marketplace/installer-guide) * [Agent Templates](/marketplace/agent-templates) * [Publishing Templates](/marketplace/publishing-templates) * [Deploying Templates](/marketplace/deploying-templates) # MCP Command Source: https://docs.heylua.ai/cli/mcp-command Manage MCP servers for your agent ## Overview The `lua mcp` command manages Model Context Protocol (MCP) servers. Use it to list, activate, deactivate, and delete MCP servers. **MCP servers from integrations**: When you connect a third-party account via `lua integrations connect`, an MCP server is automatically created and activated. These integration-based servers have a `source` field set to `unifiedto`. You can manage them with both `lua mcp` and `lua integrations mcp` commands. ```bash theme={null} lua mcp # Interactive mode lua mcp list # List all servers lua mcp activate # Activate a server lua mcp deactivate # Deactivate a server lua mcp delete # Delete a server ``` ### Non-Interactive Mode ```bash theme={null} # List all MCP servers lua mcp list # Activate a server (positional or flag style) lua mcp activate docs-server lua mcp activate --server-name docs-server # Deactivate a server lua mcp deactivate --server-name api-server # Delete a server lua mcp delete --server-name old-server ``` | Option | Description | | ---------------------- | ------------------------------------------------ | | `--server-name ` | MCP server name (for activate/deactivate/delete) | | Action | Description | Required Options | | ------------ | -------------------- | ----------------------------- | | `list` | List all MCP servers | None | | `activate` | Activate a server | `--server-name` or positional | | `deactivate` | Deactivate a server | `--server-name` or positional | | `delete` | Delete a server | `--server-name` or positional | ## Commands ### lua mcp Interactive MCP server management. ```bash theme={null} lua mcp ``` Opens an interactive menu to: * List MCP servers * Activate a server * Deactivate a server * Delete a server ### lua mcp list List all MCP servers and their status. ```bash theme={null} lua mcp list # or lua mcp ls ``` **Output:** ``` ============================================================ 🔌 MCP Servers ============================================================ ✅ Active Servers: 🟢 docs-server Transport: streamable-http URL: https://docs.example.com/mcp ID: mcp_abc123 ──────────────────────────────────────── ⏸️ Inactive Servers: ⚪ api-gateway Transport: streamable-http URL: https://api.example.com/mcp ID: mcp_def456 ============================================================ ``` ### lua mcp activate Activate an MCP server to make its tools available. ```bash theme={null} lua mcp activate # Interactive selection lua mcp activate docs-server # Activate by name # or lua mcp enable docs-server # Alias ``` **What happens:** * Server is marked as active * Server's tools become available to your agent * Tools appear in the agent's tool list at runtime **Example:** ```bash theme={null} $ lua mcp activate docs-server 🔄 Activating docs-server... ✅ MCP server "docs-server" activated successfully! 💡 The server's tools are now available to your agent. ``` ### lua mcp deactivate Deactivate an MCP server to remove its tools. ```bash theme={null} lua mcp deactivate # Interactive selection lua mcp deactivate docs-server # Deactivate by name # or lua mcp disable docs-server # Alias ``` **What happens:** * Server is marked as inactive * Server's tools are removed from agent runtime * Configuration is preserved (can be re-activated) **Example:** ```bash theme={null} $ lua mcp deactivate docs-server 🔄 Deactivating docs-server... ✅ MCP server "docs-server" deactivated successfully! 💡 The server's tools are no longer available to your agent. ``` ### lua mcp delete Permanently delete an MCP server. ```bash theme={null} lua mcp delete # Interactive selection lua mcp delete docs-server # Delete by name # or lua mcp rm docs-server # Alias ``` **This is permanent!** Deleting an MCP server removes it from the platform. You'll need to re-compile and push to recreate it. **Example:** ```bash theme={null} $ lua mcp delete docs-server ⚠️ You are about to delete MCP server: docs-server ⚠️ WARNING: This server is currently ACTIVE! ? Are you sure you want to delete this MCP server? (y/N) y 🔄 Deleting docs-server... ✅ MCP server "docs-server" deleted successfully! ``` ## Command Aliases | Command | Alias | | -------------------- | ----------------- | | `lua mcp list` | `lua mcp ls` | | `lua mcp activate` | `lua mcp enable` | | `lua mcp deactivate` | `lua mcp disable` | | `lua mcp delete` | `lua mcp rm` | ## Pushing MCP Servers MCP servers can also be pushed using the `lua push` command: ```bash theme={null} # Push a single MCP server lua push mcp # Select server from list # Push all components including MCP servers lua push all --force ``` When pushing all with `--auto-deploy`, MCP servers are automatically activated: ```bash theme={null} lua push all --force --auto-deploy ``` ## Workflow Example Create the server in your code: ```typescript theme={null} // src/mcp/docs.ts import { LuaMCPServer, env } from 'lua-cli'; export const docsServer = new LuaMCPServer({ name: 'docs-server', transport: 'streamable-http', url: 'https://docs.example.com/mcp', headers: () => ({ 'Authorization': `Bearer ${env("DOCS_API_KEY")}` }) }); ``` ```typescript theme={null} // src/index.ts import { LuaAgent } from 'lua-cli'; import { docsServer } from './mcp/docs'; export const agent = new LuaAgent({ name: 'my-agent', persona: '...', mcpServers: [docsServer] }); ``` ```bash theme={null} lua compile # Server is registered, ID assigned ``` ```bash theme={null} lua push mcp # or lua push all --force ``` ```bash theme={null} lua mcp activate docs-server # Tools are now available ``` ```bash theme={null} lua mcp list # Shows docs-server as active ``` ## Tips Use `lua mcp list` to see current status before making changes ```bash theme={null} lua mcp list # Shows which servers are active/inactive ``` Keep unused servers deactivated to reduce overhead ```bash theme={null} lua mcp activate docs-server # When needed lua mcp deactivate docs-server # When done ``` In CI/CD, use `lua push all --force` to avoid prompts ```bash theme={null} lua push all --force --auto-deploy ``` ## Related Commands Compile and register MCP servers Push MCP server configuration ## See Also * [MCP Servers Overview](/overview/mcp-servers) - Conceptual introduction * [LuaMCPServer API](/api/luamcpserver) - Complete API reference * [Skill Management](/cli/skill-management) - Compile and push commands * [Integrations Command](/cli/integrations-command) - Connect third-party accounts (auto-creates MCP servers) # Non-Interactive Mode Source: https://docs.heylua.ai/cli/non-interactive-mode Automate CLI commands for AI IDEs, CI/CD pipelines, and scripting ## Overview All Lua CLI commands now support **non-interactive mode**, enabling seamless automation for: * **AI IDEs** (Cursor, GitHub Copilot, Windsurf, etc.) * **CI/CD pipelines** (GitHub Actions, GitLab CI, Jenkins) * **Shell scripting** and automation * **Programmatic agent management** Non-interactive mode bypasses all prompts by providing arguments and flags directly on the command line. Every command now has non-interactive options with consistent naming patterns. **Building with Cursor, Windsurf, or GitHub Copilot?** See the complete AI Agent Building Guide for the full end-to-end workflow including authentication, testing strategies, sandbox vs production, common gotchas, and debugging. ## Design Patterns ### Consistent Option Naming All commands follow these patterns: | Pattern | Description | Example | | -------------------- | -------------------------- | ----------------------- | | `---name` | Select entity by name | `--skill-name mySkill` | | `---version` | Specify version | `--skill-version 1.0.5` | | `--force` | Skip confirmation prompts | `--force` | | `--json` | Output as JSON for parsing | `--json` | | `[action]` | Positional action argument | `lua jobs view` | ### Action Arguments Many commands accept an action as the first argument: ```bash theme={null} lua skills view # View all skills lua skills versions --skill-name x # View versions for skill x lua skills deploy --skill-name x # Deploy skill x ``` ## CI/CD Mode (--ci flag) The global `--ci` flag makes the CLI fail loudly on missing required arguments instead of silently prompting (which fails in non-TTY environments). In CI/CD pipelines and non-TTY environments (piped input, background jobs), interactive prompts silently fail, causing commands to cancel without clear error messages. The `--ci` flag prevents this by throwing errors when required arguments are missing: ```bash theme={null} # Without --ci (fails silently in CI/CD) lua push skill # Silently cancels - no clear error # With --ci (fails loudly with helpful error) lua --ci push skill # Error: Interactive prompt required but --ci flag is set. Provide all required flags or arguments. ``` ### Usage The `--ci` flag is a **global flag** that must come before the command: ```bash theme={null} # Correct lua --ci push skill --name mySkill --set-version 1.0.0 --force # Incorrect lua push skill --ci # Won't work - must be global ``` ### When to Use **Always use `--ci` in automated environments:** ```yaml theme={null} # GitHub Actions - name: Push skill run: lua --ci push skill --name mySkill --set-version ${{ github.sha }} --force # GitLab CI script: - lua --ci push all --force --auto-deploy ``` **Why**: Ensures missing flags cause immediate, clear failures instead of silent hangs. **Don't use `--ci` locally:** ```bash theme={null} # Local - interactive prompts are helpful lua push skill ? Select a skill: mySkill ? Enter version: 1.0.1 # CI/CD - no prompts, just errors lua --ci push skill # Error: missing --name flag ``` **Why**: Interactive prompts provide guidance and defaults. **Use `--ci` in bash scripts:** ```bash theme={null} #!/bin/bash set -e # Fail fast if required args missing lua --ci push skill \ --name "$SKILL_NAME" \ --set-version "$VERSION" \ --force ``` **Why**: Scripts should fail fast and clearly, not hang waiting for input. ### Behavior Differences | Scenario | Without --ci | With --ci | | --------------------- | -------------------------- | ------------------------------ | | Missing required flag | Shows prompt (hangs in CI) | Throws error immediately | | Non-TTY environment | Shows warning, continues | Throws error if prompts needed | | All flags provided | Works normally | Works normally | ### Auto-Detection Even without `--ci`, the CLI automatically detects non-TTY environments and shows a warning: ```bash theme={null} echo "" | lua push skill # Warning: stdin is not a TTY. Interactive prompts may not work correctly. # Tip: Use --ci flag in CI/CD environments to fail loudly on missing required flags. ``` However, this still allows the command to continue (and likely fail silently). Using `--ci` prevents this by failing immediately. ## Complete Command Reference ### Project Setup Initialize a new project without prompts: ```bash theme={null} # Use existing agent lua init --agent-id abc123 # Create agent in existing organization lua init --agent-name "My Bot" --org-id org456 # Create agent + new organization lua init --agent-name "My Bot" --org-name "Acme Corp" # Create agent with a specific model lua init --agent-name "My Bot" --org-id org456 --model openai/gpt-4o # Override existing project lua init --agent-id abc123 --force # With example code lua init --agent-id abc123 --with-examples ``` | Option | Description | | --------------------- | ---------------------------------------------- | | `--agent-id ` | Use existing agent by ID | | `--agent-name ` | Name for new agent | | `--org-id ` | Existing organization ID | | `--org-name ` | New organization name | | `--model ` | LLM model for the agent (e.g. `openai/gpt-4o`) | | `--force` | Override existing `lua.skill.yaml` | | `--with-examples` | Include example code | ### Development & Testing Test skills, webhooks, or jobs without prompts: ```bash theme={null} # Test a skill/tool lua test skill --name get_weather --input '{"city": "London"}' # Test a webhook lua test webhook --name payment-hook --input '{"query": {}, "headers": {}, "body": {"type": "payment"}}' # Test a job lua test job --name daily-report # Test preprocessor lua test preprocessor --name filter --input '{"message": "hello", "channel": "web"}' # Test postprocessor lua test postprocessor --name formatter --input '{"message": "hi", "response": "hello", "channel": "web"}' ``` | Option | Description | | ---------------- | ---------------------- | | `--name ` | Entity name to test | | `--input ` | JSON input for testing | Detect and resolve drift without prompts: ```bash theme={null} # Check for drift (exit code 1 if found) lua sync --check # Auto-accept server state (pull server → local) lua sync --accept # Push local state to server without prompting lua sync --push ``` | Option | Description | | ---------- | --------------------------- | | `--check` | Check only, exit 1 if drift | | `--accept` | Pull server state to local | | `--push` | Push local state to server | Send messages without interactive session: ```bash theme={null} # Send to sandbox lua chat -e sandbox -m "Hello, what can you do?" # Send to production lua chat -e production -m "Help me with my order" # Default: sandbox lua chat -m "Quick test message" # Scoped to an explicit thread lua chat -m "Run scenario A" --thread scenario-a # Auto-generated thread (UUID printed at start) lua chat -m "Isolated test" --thread # Isolated test with auto-clear after response lua chat -m "Run test" -t my-test --clear # Send multiple messages concurrently (load / batch testing) lua chat -b "Message 1" "Message 2" "Message 3" # Batch with delay between messages (ms) lua chat -b "Msg 1" "Msg 2" "Msg 3" -d 500 ``` **File attachments** — use `@` inside the message string: ```bash theme={null} # Attach an image lua chat -m "@screenshot.png what's wrong with this UI?" -e sandbox # Attach a document lua chat -m "@report.pdf summarize this" -e production # Multiple attachments lua chat -m "compare @before.png and @after.png" -e sandbox ``` | Option | Description | | --------------------------- | --------------------------------------------------------- | | `-e, --env ` | Environment: sandbox or production | | `-m, --message ` | Message to send (supports `@` file attachments) | | `-t, --thread [id]` | Scope to a thread (omit ID to auto-generate UUID) | | `--clear`, `--clear-thread` | Clear thread history after response (requires `--thread`) | | `-b, --batch ` | Send multiple messages concurrently | | `-d, --delay ` | Delay between batch messages in ms (default: 100) | ### Deployment Push components without prompts: ```bash theme={null} # Push specific skill with version lua push skill --name mySkill --set-version 1.0.5 # Push all components lua push all --force # Push and auto-deploy to production lua push all --force --auto-deploy # Push webhook lua push webhook --name payment-hook --set-version 2.0.0 # Push job lua push job --name daily-report --set-version 1.0.0 ``` | Option | Description | | --------------------- | ------------------- | | `--name ` | Entity name to push | | `--set-version ` | Version to set | | `--force` | Skip confirmations | | `--auto-deploy` | Deploy after push | Deploy to production without prompts: ```bash theme={null} # Deploy specific version lua deploy --skill-name mySkill --skill-version 1.0.5 --force # Deploy latest version lua deploy --skill-name mySkill --skill-version latest --force ``` | Option | Description | | ----------------------- | ------------------- | | `--skill-name ` | Skill to deploy | | `--skill-version ` | Version or 'latest' | | `--force` | Skip confirmation | If the agent has already promoted an agent version, `lua deploy` performs a scoped promote (creates and promotes a new agent version identical to the current one, except for the deployed primitive) so the deploy is live immediately and shows up in `lua version list`. For CI/CD pipelines that want to snapshot and promote every primitive together instead, see [`lua version create --auto-push`](/cli/version-command#lua-version-create) and [`lua version promote`](/cli/version-command#lua-version-promote). ### Entity Management Manage skills without prompts: ```bash theme={null} # View all production skills lua skills view # View skill versions lua skills versions --skill-name mySkill # Deploy specific version lua skills deploy --skill-name mySkill --skill-version 1.0.3 # Deploy latest lua skills deploy --skill-name mySkill --skill-version latest ``` | Action | Options Required | | ---------- | --------------------------------- | | `view` | None | | `versions` | `--skill-name` | | `deploy` | `--skill-name`, `--skill-version` | Manage webhooks without prompts: ```bash theme={null} lua webhooks view lua webhooks activate --webhook-name myWebhook lua webhooks deactivate --webhook-name myWebhook lua webhooks versions --webhook-name myWebhook lua webhooks deploy --webhook-name myWebhook --webhook-version 1.0.3 # Event subscriptions lua webhooks list-events lua webhooks subscribe --webhook-name myWebhook --event message.delivered lua webhooks unsubscribe --webhook-name myWebhook --event message.delivered ``` | Action | Options Required | | ------------- | ------------------------------------- | | `view` | None | | `activate` | `--webhook-name` | | `deactivate` | `--webhook-name` | | `versions` | `--webhook-name` | | `deploy` | `--webhook-name`, `--webhook-version` | | `list-events` | None | | `subscribe` | `--webhook-name`, `--event` | | `unsubscribe` | `--webhook-name`, `--event` | **Available event types (WhatsApp):** | Event | Description | | ------------------- | ----------------------------------------------- | | `message.sent` | Message was sent to the recipient | | `message.delivered` | Message was delivered to the recipient's device | | `message.read` | Recipient read the message | | `message.failed` | Message failed to send | | `message.played` | Recipient played a voice/video message | Manage scheduled jobs without prompts: ```bash theme={null} lua jobs view lua jobs trigger -i healthCheck lua jobs activate -i myJob lua jobs deactivate -i myJob lua jobs versions -i myJob lua jobs deploy -i myJob -v 1.0.3 lua jobs deploy -i myJob -v latest lua jobs history -i myJob lua jobs delete -i oldJob ``` | Option | Description | | ----------------------------- | --------------------------------- | | `-i, --job-name ` | Job name | | `-v, --job-version ` | Version or 'latest' (deploy only) | | Action | Options Required | | ------------ | ----------------------------- | | `view` | None | | `trigger` | `--job-name` | | `activate` | `--job-name` | | `deactivate` | `--job-name` | | `versions` | `--job-name` | | `deploy` | `--job-name`, `--job-version` | | `history` | `--job-name` | | `delete` | `--job-name` | Manage preprocessors without prompts: ```bash theme={null} lua preprocessors view lua preprocessors activate --preprocessor-name myPre lua preprocessors deactivate --preprocessor-name myPre lua preprocessors versions --preprocessor-name myPre lua preprocessors deploy --preprocessor-name myPre --preprocessor-version 1.0.3 lua preprocessors delete --preprocessor-name oldPre ``` Manage postprocessors without prompts: ```bash theme={null} lua postprocessors view lua postprocessors activate --postprocessor-name myPost lua postprocessors deactivate --postprocessor-name myPost lua postprocessors versions --postprocessor-name myPost lua postprocessors deploy --postprocessor-name myPost --postprocessor-version 1.0.3 lua postprocessors delete --postprocessor-name oldPost ``` ### Configuration Manage environment variables without prompts: ```bash theme={null} # List variables lua env sandbox --list lua env production --list # Set variable lua env sandbox -k DATABASE_URL -v "postgres://localhost/db" lua env production -k API_KEY -v "sk_live_xxx" # Delete variable lua env production -k OLD_KEY --delete ``` | Option | Description | | ------------------- | ------------------- | | `--list` | List all variables | | `-k, --key ` | Variable name | | `-v, --value ` | Variable value | | `-d, --delete` | Delete the variable | Manage persona without prompts: ```bash theme={null} # View current persona lua persona production view # List versions lua persona production versions # Deploy specific version lua persona production deploy --persona-version 5 # Deploy latest lua persona production deploy --persona-version latest --force ``` | Action | Options | | ---------- | ------------------------------ | | `view` | None | | `versions` | None | | `deploy` | `--persona-version`, `--force` | Manage features without prompts: ```bash theme={null} lua features list lua features enable --feature-name rag lua features disable --feature-name rag lua features view --feature-name webSearch ``` | Action | Options Required | | --------- | ---------------- | | `list` | None | | `enable` | `--feature-name` | | `disable` | `--feature-name` | | `view` | `--feature-name` | Manage knowledge base without prompts: ```bash theme={null} lua resources list lua resources view --resource-name "FAQ Document" lua resources delete --resource-name "Old Document" ``` | Action | Options Required | | -------- | ----------------- | | `list` | None | | `view` | `--resource-name` | | `delete` | `--resource-name` | Manage MCP servers without prompts: ```bash theme={null} lua mcp list lua mcp activate --server-name filesystem lua mcp deactivate --server-name api-server lua mcp delete --server-name old-server # Positional style also works lua mcp activate filesystem ``` | Action | Options Required | | ------------ | ---------------- | | `list` | None | | `activate` | `--server-name` | | `deactivate` | `--server-name` | | `delete` | `--server-name` | Connect and manage third-party integrations without prompts: **Discovery Commands (for AI agents and scripting):** ```bash theme={null} # View available integrations lua integrations available # Get integration details (scopes and triggers) lua integrations info linear lua integrations info linear --json # List available trigger events lua integrations webhooks events --integration linear lua integrations webhooks events --integration linear --json ``` **Connect with Triggers:** ```bash theme={null} # Connect with triggers enabled (recommended) lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Connect with all available triggers lua integrations connect --integration linear --auth-method oauth --scopes all --triggers all # Connect without triggers lua integrations connect --integration linear --auth-method oauth --scopes all # Connect with custom webhook URL instead of agent trigger lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created --custom-webhook --hook-url https://my-server.com/webhook # List connections lua integrations list # Update connection scopes lua integrations update --integration linear --scopes "task_task_read,task_task_write" # Disconnect lua integrations disconnect --connection-id 6978e0294d9c2007ed5cb129 ``` **Connect Options:** | Option | Description | | ------------------------- | ---------------------------------------- | | `--integration ` | Integration type (linear, discord, etc.) | | `--auth-method ` | `oauth` or `token` | | `--scopes ` | Comma-separated scopes or `all` | | `--hide-sensitive ` | Hide sensitive data (default: true) | | `--triggers ` | Comma-separated triggers or `all` | | `--custom-webhook` | Use custom URL instead of agent trigger | | `--hook-url ` | Custom webhook URL | | `--connection-id ` | Connection ID (for disconnect) | **Info Options:** | Option | Description | | -------- | ---------------------------- | | `--json` | Output as JSON for scripting | **Trigger Management:** ```bash theme={null} # List triggers lua integrations webhooks list # List available trigger events lua integrations webhooks events --integration linear --json # Create trigger (agent wake-up mode - default) lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event created # Create trigger with custom webhook URL lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event created \ --hook-url https://my-server.com/webhook # Create trigger with custom polling interval lua integrations webhooks create \ --connection abc123 \ --object task_task \ --event updated \ --interval 120 # Delete trigger lua integrations webhooks delete --webhook-id wh_xyz789 ``` **Trigger Options:** | Option | Description | | ---------------------- | ----------------------------------------------------- | | `--connection ` | Connection ID | | `--integration ` | Integration type (for events discovery) | | `--object ` | Object type (task\_task, calendar\_event, etc.) | | `--event ` | Event: created, updated, deleted | | `--hook-url ` | Custom webhook URL (default: agent trigger) | | `--interval ` | Polling interval (60, 120, 240, 480, 720, 1440, 2880) | | `--webhook-id ` | Trigger ID (for delete) | | `--json` | Output as JSON (for events command) | **MCP Server Management:** ```bash theme={null} lua integrations mcp list lua integrations mcp activate --connection abc123 lua integrations mcp deactivate --connection abc123 ``` ### Viewing & Debugging View logs without prompts: ```bash theme={null} # View all logs lua logs --type all --limit 50 # Filter by type lua logs --type skill --limit 20 lua logs --type webhook --limit 20 lua logs --type job --limit 20 # Filter by specific entity lua logs --type skill --name mySkill --limit 10 # Pagination lua logs --type all --limit 20 --page 2 # JSON output for scripting lua logs --type all --json # Inspect logs for another agent you own lua logs --agent-id agent-abc123 --type skill --limit 10 ``` | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------- | | `--type ` | all, skill, job, webhook, preprocessor, postprocessor, user\_message, agent\_response | | `--name ` | Entity name (requires --type, not for message types) | | `--user-id ` | Filter logs by user ID | | `--agent-id ` | Target a specific agent (admin access required) | | `--limit ` | Number of logs (default: 20) | | `--page ` | Page number | | `--json` | JSON output | View production state without prompts: ```bash theme={null} lua production overview # Production summary lua production persona # Current persona lua production skills # Deployed skills lua production env # Environment variables ``` View channels without prompts: ```bash theme={null} lua channels list # List all channels ``` ### Authentication & Utilities Authentication commands with --force: ```bash theme={null} # View API key without confirmation lua auth key --force # Logout without confirmation lua auth logout --force ``` Clear history without confirmation: ```bash theme={null} # Clear all history lua chat clear --force # Clear specific user's history lua chat clear --user user@email.com --force lua chat clear --user +1234567890 --force # Clear a specific thread's history lua chat clear --thread my-test-scenario --force ``` `--user` requires `org:manage` for the agent. Organization-admin grants do not cascade to private agents; organization owners and sufficient explicit agent grants retain access under the shared authorization rules. Without it, the command clears only the authenticated caller's history. ### Marketplace `lua marketplace [noun] [action]`, where `noun` is `skill` or `template`. See the [Marketplace Command reference](/cli/marketplace-command) for the full flag set. Publish, manage, and install skills — all in one flat action namespace: ```bash theme={null} # List a skill (public by default) lua marketplace skill list --skill-name mySkill --display-name "My Skill" # List a private skill (org-only) lua marketplace skill list --skill-name mySkill --display-name "My Skill" --visibility private # Publish a version lua marketplace skill publish --marketplace-id xyz --version-id v1 --changelog "Bug fixes" # Edit metadata lua marketplace skill edit --marketplace-id xyz --display-name "New Name" # Unlist lua marketplace skill unlist --marketplace-id xyz --force # Unpublish version lua marketplace skill unpublish --marketplace-id xyz --version-id v1 --force # View my listings lua marketplace skill mine --json # Search marketplace lua marketplace skill search --query "CRM" --limit 10 --json # View skill details lua marketplace skill view --marketplace-id xyz --json # Install skill lua marketplace skill install --marketplace-id xyz --version-id v1 --force # With environment variables lua marketplace skill install --marketplace-id xyz --version-id v1 --env-vars "API_KEY=xxx,SECRET=yyy" --force # Update installed skill lua marketplace skill update --skill-name mySkill --version-id v2 # Uninstall lua marketplace skill uninstall --skill-name mySkill --force # List installed lua marketplace skill installed --json ``` Publish and roll out agent templates — see [Agent Templates](/marketplace/agent-templates) for the full lifecycle: ```bash theme={null} # Create a template from the current agent (private by default) lua marketplace template create --name support-bot --display-name "Support Bot" # Compose the template: section of lua.skill.yaml for review lua marketplace template draft --template-id tpl_abc # Publish a version, declaring its env contract lua marketplace template publish --template-id tpl_abc --changelog "Bug fixes" \ --env-contract "STRIPE_KEY=Stripe secret key" # In scripts: --yes auto-confirms only the section clear/narrow consequence prompt lua marketplace template publish --template-id tpl_abc --yes # Install onto the current agent lua marketplace template install --template-id tpl_abc --env-vars "STRIPE_KEY=sk_xxx" --force # Roll a new version out to a fleet lua marketplace template apply --template-id tpl_abc --all-installed --force # Check the fleet ledger lua marketplace template status --template-id tpl_abc --json # List templates installed on this agent lua marketplace template installed --json # Uninstall from this agent lua marketplace template uninstall --template-id tpl_abc --force ``` ## Example Workflows ### AI IDE Workflow (Cursor, Copilot) When an AI assistant needs to manage your agent: ```bash theme={null} # Initialize project for existing agent lua init --agent-id agent_abc123 # Set environment variables lua env sandbox -k OPENAI_KEY -v "sk-xxx" lua env production -k OPENAI_KEY -v "sk-yyy" # Discover and connect third-party integrations lua integrations available lua integrations info linear --json # Get scopes and triggers as JSON lua integrations connect --integration linear --auth-method oauth --scopes all \ --triggers task_task.created,task_task.updated # Connect with triggers # Verify integration lua integrations list lua integrations webhooks list # Test a specific tool lua test skill --name get_order --input '{"orderId": "123"}' # Push and deploy lua push skill --name order-service --set-version 1.0.0 --force lua deploy --skill-name order-service --skill-version latest --force # Check logs lua logs --type skill --name order-service --limit 10 --json ``` ### GitHub Actions CI/CD ```yaml theme={null} name: Deploy Agent on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest env: LUA_API_KEY: ${{ secrets.LUA_API_KEY }} steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci - name: Install Lua CLI run: npm install -g lua-cli - name: Check for drift run: lua sync --check - name: Compile run: lua compile - name: Push version run: lua push skill --name ${{ github.event.repository.name }} --set-version ${{ github.sha }} --force - name: Deploy to production run: lua deploy --skill-name ${{ github.event.repository.name }} --skill-version latest --force - name: Verify deployment run: lua logs --type skill --name ${{ github.event.repository.name }} --limit 5 --json ``` ### Post-deploy verification (CI-friendly) After any production deploy, run a 3-line probe to confirm the new version is healthy. This is the CI version of the canonical [post-deploy debug loop](/cli/debugging): ```bash theme={null} # Push and auto-deploy in one shot lua --ci push all --force --auto-deploy # Send a probe message to production lua --ci chat -m "verify production" -e production -t prod-verify --clear # Confirm zero agent errors fired during the probe lua logs --type agent_error --limit 5 --json | jq '.logs | length' ``` Wire the third line into your pipeline's exit code so a non-zero error count fails the build: ```bash theme={null} errors=$(lua logs --type agent_error --limit 5 --json | jq '.logs | length') if [ "$errors" -gt 0 ]; then echo "Production has $errors agent errors after deploy — failing build." exit 1 fi ``` **Silencing post-action hints in CI.** The CLI prints `✨ Tip:` / `💡 Diagnose:` lines and a quiet `agent_error` probe summary after each command. They're useful interactively but noisy when CI captures only command output. Set `LUA_NO_HINTS=1` to silence all of them: ```bash theme={null} export LUA_NO_HINTS=1 lua --ci push all --force --auto-deploy lua --ci chat -m "verify production" -e production ``` ### Bash Scripting ```bash theme={null} #!/bin/bash set -e SKILL_NAME="my-skill" VERSION=$(date +%Y%m%d%H%M%S) echo "Deploying $SKILL_NAME version $VERSION" # Check drift if ! lua sync --check; then echo "Drift detected! Run 'lua sync' manually to resolve." exit 1 fi # Compile and push lua compile lua push skill --name "$SKILL_NAME" --set-version "$VERSION" --force # Deploy lua deploy --skill-name "$SKILL_NAME" --skill-version latest --force # Verify lua logs --type skill --name "$SKILL_NAME" --limit 5 echo "Deployment complete!" ``` ### Multi-Component Deployment ```bash theme={null} #!/bin/bash # Deploy all components at once VERSION="2.0.0" # Push everything lua push all --force # Or push individually with versions lua push skill --name order-service --set-version $VERSION --force lua push webhook --name payment-hook --set-version $VERSION --force lua push job --name daily-report --set-version $VERSION --force # Deploy skills lua deploy --skill-name order-service --skill-version latest --force # Activate webhooks and jobs lua webhooks activate --webhook-name payment-hook lua jobs activate --job-name daily-report # Subscribe webhook to delivery events lua webhooks subscribe --webhook-name payment-hook --event message.delivered lua webhooks subscribe --webhook-name payment-hook --event message.failed # Verify lua production overview ``` ## Exit Codes All commands return consistent exit codes: | Code | Meaning | | ---- | ------------------------------------------ | | `0` | Success | | `1` | Error (missing args, not found, API error) | Use exit codes in scripts: ```bash theme={null} if lua sync --check; then echo "No drift" else echo "Drift detected" exit 1 fi ``` ## JSON Output Commands with `--json` output machine-readable JSON: ```bash theme={null} # Get logs as JSON logs=$(lua logs --type all --limit 5 --json) # Parse with jq echo "$logs" | jq '.logs[].message' # Get installed skills skills=$(lua marketplace skill installed --json) ``` ## Best Practices In automated pipelines, always use `--force` to skip confirmations: ```bash theme={null} lua push all --force lua deploy --skill-name x --skill-version latest --force ``` Use `lua sync --check` in CI to fail builds on drift: ```bash theme={null} lua sync --check || { echo "Drift detected"; exit 1; } ``` When processing output programmatically, use `--json`: ```bash theme={null} lua logs --json | jq '.logs | length' ``` Use dynamic versions in CI: ```bash theme={null} lua push skill --name x --set-version $(git rev-parse --short HEAD) --force ``` ## Related All available commands init, test, push, deploy details Drift detection details Managing configuration # CLI Overview Source: https://docs.heylua.ai/cli/overview Command-line interface for building and deploying AI agents ## Lua AI CLI Build and deploy AI agents with superpowers. ```bash theme={null} lua [options] [command] ``` Looking for what's new? See the [changelog](/changelog) for release notes. ### Options ```bash theme={null} -V, --version # Output the version number -h, --help # Display help for command ``` ### All Commands | Command | Category | Description | Non-Interactive | | -------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | ---------------------- | | `lua auth` | 🔐 Authentication | Manage API keys and authentication | ✅ `--force` | | `lua init` | 🚀 Project Setup | Initialize a new Lua skill project | ✅ Full support | | `lua env` | ⚙️ Configuration | Manage environment variables (sandbox/production) | ✅ Full support | | `lua persona` | 🤖 Configuration | Manage agent persona (sandbox/production) | ✅ Full support | | `lua features` | ⚡ Configuration | Manage agent capabilities and features | ✅ Full support | | `lua resources` | 📚 Configuration | Manage agent knowledge base | ✅ Full support | | `lua skills` | 🔧 Development | View and manage skills (sandbox/production) | ✅ Full support | | `lua compile` | 📦 Development | Compile skill to deployable format | ✅ `--sync` `--verbose` | | `lua sync` | 🔄 Development | Detect and resolve drift between server and local code | ✅ Full support | | `lua test` | 💬 Testing | Test skill tools interactively | ✅ Full support | | `lua chat` | 💬 Testing | Interactive chat with your agent | ✅ `-e` `-m` | | `lua push` | ☁️ Deployment | Push components to server | ✅ Full support | | `lua deploy` | 🚀 Deployment | Deploy one primitive's version to production (legacy; scoped-promotes when the agent has agent versions) | ✅ Full support | | `lua version` | 🚀 Deployment | Create, promote, and inspect atomic agent versions — recommended release flow | ✅ Full support | | `lua mcp` | 🔌 Integration | Manage MCP (Model Context Protocol) servers | ✅ Full support | | `lua integrations` | 🔌 Integration | Connect third-party accounts via Unified.to | ✅ Full support | | `lua production` | 🚀 Deployment | View and manage production environment | ✅ Full support | | `lua channels` | 🔌 Integration | Manage communication channels | ✅ `list` | | `lua webhooks` | 🪝 Primitives | View, deploy, activate, and subscribe webhook primitives | ✅ Full support | | `lua jobs` | ⏰ Primitives | View, deploy, activate, trigger, and inspect scheduled jobs | ✅ Full support | | `lua preprocessors` | 📥 Primitives | Manage message preprocessor versions | ✅ Full support | | `lua postprocessors` | 📤 Primitives | Manage response postprocessor versions | ✅ Full support | | `lua devices` | 📡 Primitives | Manage connected IoT/physical devices | ✅ Full support | | `lua voice` | 🎙️ Testing | Test voice agents live in the browser and run voice test suites | ✅ Full support | | `lua marketplace` | 🛒 Ecosystem | Publish/install skills and agent templates via `lua marketplace [skill\|template]` | ✅ Full support | | `lua source` | 🗂️ Workspace | List and roll back to past workspace source versions | ✅ Full support | | `lua models` | 🧠 Configuration | List and select the LLM model for your agent | ✅ `--model` `--json` | | `lua governance` | 🛡️ Configuration | Add or remove governance enforcement | ✅ Full support | | `lua triggers` | ⚡ Integration | Manage integration triggers (alias for `lua integrations webhooks`) | ✅ Full support | | `lua logs` | 🐛 Debugging | View and filter agent logs by component type (Skills, Jobs, Webhooks, etc.) | ✅ Full support | | `lua status` | 🛠️ Utilities | Full agent state at a glance (alias: `lua describe`) | ✅ `--json` | | `lua agents` | 🛠️ Utilities | List all organizations and agents you have access to | ✅ `--json` | | `lua update` | 🛠️ Utilities | Update lua-cli to the latest version | ✅ Direct | | `lua completion` | ⌨️ Utilities | Generate shell autocomplete scripts | ✅ Direct | | `lua admin` | 🛠️ Utilities | Open admin dashboard in browser | ✅ Direct | | `lua evals` | 📊 Utilities | Open evaluations dashboard in browser | ✅ Direct | | `lua docs` | 📖 Utilities | Open documentation in browser | ✅ Direct | | `lua telemetry` | ⚙️ Utilities | Manage usage data collection | ✅ Direct | **For AI IDEs**: Complete workflow for Cursor, Windsurf, GitHub Copilot Command reference for automation, CI/CD, and scripting ### Quick Examples ```bash theme={null} # Authentication & Setup lua auth configure # 🔑 Create or save a CLI credential lua init # 🚀 Initialize a new project # Configuration (Interactive or Direct) lua env # ⚙️ Manage environment variables lua env sandbox # 🎯 Direct: manage sandbox .env file lua env production # 🎯 Direct: manage production env vars lua persona # 🤖 Manage agent persona lua persona sandbox # 🎯 Direct: edit sandbox persona lua persona production # 🎯 Direct: deploy persona version lua features # ⚡ Manage agent capabilities (RAG, webSearch, inquiry) lua resources # 📚 Manage knowledge base lua marketplace # 🛒 Access marketplace (skills or agent templates) # Development lua skills # 🔧 View and manage skills lua skills sandbox # 🎯 Direct: view local skills lua skills production # 🎯 Direct: manage production skills lua compile # 📦 Compile your skills lua sync # 🔄 Check for drift between server and local lua test # 🧪 Test tools interactively lua chat # 💬 Start interactive chat # Deployment (Interactive or Direct) lua push # ☁️ Push to server (choose component) lua push skill # 🎯 Direct: push skill lua push persona # 🎯 Direct: push persona lua push mcp # 🎯 Direct: push MCP server lua push all --force # 🎯 Push all components lua version create # 📸 Snapshot the latest pushed version of every primitive lua version promote 2 # 🚀 Atomically activate a version — recommended release step lua deploy # 🚀 Deploy one primitive directly (legacy, still supported) # Integration & Debugging lua mcp # 🔌 Manage MCP servers (list, activate, deactivate) lua integrations # 🔌 Connect third-party accounts (Linear, Discord, etc.) lua channels # 🔌 Manage communication channels lua logs # 🐛 View and filter execution logs (interactive filtering) # Utilities lua completion bash # ⌨️ Generate bash autocomplete lua completion zsh # ⌨️ Generate zsh autocomplete lua completion fish # ⌨️ Generate fish autocomplete lua admin # 🛠️ Open admin dashboard lua evals # 📊 Open evaluations dashboard lua docs # 📖 Open documentation ``` **Get help for any command**: `lua [command] --help` or `lua help [command]` **Documentation**: [https://docs.heylua.ai](https://docs.heylua.ai)\ **Support**: [https://heylua.ai/support](https://heylua.ai/support) ## Quick Command Reference Initialize new skill project View and manage skills Manage environment variables Configure agent personality Manage agent capabilities Manage MCP servers Connect third-party accounts Detect and resolve drift Test tools interactively Interactive chat with your agent Upload version to server Deploy one primitive directly (legacy) Snapshot and atomically promote a release Shell autocomplete setup ## Shell Autocomplete Enable tab completion for all Lua CLI commands and arguments: ```bash theme={null} # One-time setup lua completion bash >> ~/.bashrc source ~/.bashrc # Now try: lua pu # Completes to: lua push lua push # Shows: skill, persona lua env # Shows: sandbox, staging, production ``` ```bash theme={null} # One-time setup lua completion zsh >> ~/.zshrc source ~/.zshrc # Now try: lua # Shows all commands lua persona # Shows: sandbox, staging, production ``` ```bash theme={null} # One-time setup lua completion fish > ~/.config/fish/completions/lua.fish # Now try: lua # Shows all commands with descriptions ``` ## Direct Mode Skip interactive prompts by specifying your target directly: ```bash theme={null} # Interactive (old way - still works!) lua push ? What would you like to push? › skill persona # Direct (new way - faster!) lua push skill # Push skill directly lua push persona # Push persona directly ``` ```bash theme={null} # Interactive lua env ? Select environment: › Sandbox Production # Direct lua env sandbox # Manage .env file lua env production # Manage API env vars lua env staging # Alias for sandbox ``` ```bash theme={null} # Interactive lua persona ? Select environment: › Sandbox Production # Direct lua persona sandbox # Edit sandbox persona lua persona production # Deploy persona version ``` ```bash theme={null} # Interactive lua skills ? Select environment: › Sandbox Production # Direct lua skills sandbox # View local skills lua skills production # Manage production skills ``` ## Command Categories ### Authentication Sign in or set up API-key authentication ```bash theme={null} lua auth configure ``` Choose between: * **Email**: Sign in with a renewable session that follows your current organizations and agents * **API Key**: Save an existing scoped or legacy key unchanged Display stored API key ```bash theme={null} lua auth key ``` Shows key after confirmation prompt Delete stored credentials ```bash theme={null} lua auth logout ``` Removes `~/.lua-cli/credentials`. The server credential remains valid until you revoke it. ### Skill Management Create new skill project ```bash theme={null} mkdir my-skill && cd my-skill lua init # Minimal project (recommended) lua init --with-examples # Include 30+ example tools ``` **Default (minimal):** * Clean agent ready to customize * TypeScript configuration * `lua.skill.yaml` config **With --with-examples:** * 30+ example tools * Example webhooks, jobs, processors * Complete e-commerce flow examples Compile TypeScript to JavaScript ```bash theme={null} lua compile ``` Automatically: * Detects all tools * Bundles with esbuild * Creates deployment artifacts * Updates skill IDs Test tools interactively ```bash theme={null} lua test ``` Features: * Select tool from list * Dynamic input prompts * Secure sandbox execution * Detailed error reporting Upload version to server ```bash theme={null} lua push ``` * Compiles code * Validates configuration * Uploads to server * Creates new version Deploy one primitive to production (legacy, still supported) ```bash theme={null} lua deploy ``` * Lists available versions * Shows current version * Requires confirmation * Immediate deployment * Scoped-promotes into agent-version history if the agent already has one Snapshot and atomically promote every primitive — recommended release flow ```bash theme={null} lua version create --auto-push -m "release notes" lua version promote 2 ``` * Snapshots the latest pushed version of every primitive * Promote switches everything at once, no mixed-version window * Instant rollback: promote an older version * `lua version status` shows what's pushed but not yet live Interactive chat ```bash theme={null} lua chat ``` Features: * Sandbox or production mode * Test conversational flows * Skill overrides * Persona customization ## Common Workflows ### New Project Workflow ```bash theme={null} lua auth configure ``` ```bash theme={null} mkdir my-skill && cd my-skill lua init # Minimal project (recommended) # OR lua init --with-examples # Include example code ``` ```bash theme={null} lua env sandbox ``` Add API keys and configuration to .env file ```bash theme={null} lua persona sandbox ``` Define your agent's personality in local file ```bash theme={null} lua chat ``` Interactive chat in sandbox mode **Optional**: Use `lua test` to test individual tools with specific inputs ```bash theme={null} lua push lua deploy ``` ### Development Workflow ```bash theme={null} lua env sandbox # Update local .env lua env production # Update production env vars ``` Add any new API keys or configuration needed ```bash theme={null} lua persona sandbox # Edit and test locally ``` Refine agent personality based on feedback Edit your tools in `src/tools/*.ts` ```bash theme={null} lua chat ``` Select sandbox mode to test with local changes **Pro tip**: Use `lua test` to debug specific tool logic if needed ```bash theme={null} lua persona production # Deploy persona version ``` Or use: `lua push persona` ```bash theme={null} lua push skill ``` Upload new skill version ```bash theme={null} lua version create --auto-push -m "release notes" lua version promote 2 ``` Recommended once your agent has release history — snapshots and atomically activates every primitive together. `lua deploy` (single primitive, immediate) still works and is the faster path for a one-off fix; see [Version Command](/cli/version-command) for the full comparison. ### Quick Fix Workflow ```bash theme={null} # Edit the tool vim src/tools/MyTool.ts # Test the tool lua test # Test conversationally lua chat # Push and deploy (direct mode - faster!) lua push skill && lua deploy ``` ## Global Flags All commands support: ```bash theme={null} --help Show command help --version Show CLI version ``` ## Environment Variables Commands automatically load environment variables from: Variables from your shell Variables from project `.env` Variables from skill config ## Error Handling All commands include: * ✅ Descriptive error messages * ✅ Exit codes (0 = success, 1 = error) * ✅ Troubleshooting hints ### Common Errors ``` ❌ No Lua CLI authentication found. Run `lua auth configure` or set `LUA_API_KEY`. ``` **Solution**: Run `lua auth configure` ``` ❌ No lua.skill.yaml found. Please run this command from a skill directory. ``` **Solution**: Run command from skill directory or run `lua init` first ``` ❌ Version 1.0.0 already exists on the server ``` **Solution**: Increment the version number in `lua.skill.yaml` (this is the only field you should manually edit) ``` ❌ No index.ts found in current directory or src/ directory ``` **Solution**: Create `index.ts` or `src/index.ts` with skill definition ## Agent Structure ### Project Code Pattern Use `LuaAgent` for unified configuration: ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill, LuaWebhook, LuaJob, LuaMCPServer } from "lua-cli"; import { mySkill } from "./skills/my-skill"; import paymentWebhook from "./webhooks/payment"; import dailyReportJob from "./jobs/daily-report"; import filesystemServer from "./mcp/filesystem"; export const agent = new LuaAgent({ name: "my-agent", // Define personality and behavior persona: `You are a helpful assistant for Acme Corp. Your role: - Help customers with orders and support - Answer product questions - Process payments Communication style: - Professional yet friendly - Clear and concise - Patient and understanding`, // Skills with tools skills: [mySkill], // HTTP endpoints (optional) webhooks: [paymentWebhook], // Scheduled tasks (optional) jobs: [dailyReportJob], // MCP servers for external tools (optional) mcpServers: [filesystemServer], }); ``` ### Project Structure ``` my-agent/ ├── src/ │ ├── index.ts # Agent configuration (LuaAgent) │ ├── skills/ # Skill definitions │ │ └── my-skill.ts │ ├── tools/ # Tool implementations │ │ ├── OrderLookupTool.ts │ │ └── CreateTicketTool.ts │ ├── webhooks/ # Webhook handlers │ │ └── payment.ts │ ├── jobs/ # Scheduled jobs │ │ └── daily-report.ts │ ├── mcp/ # MCP server configurations │ │ └── filesystem.ts │ ├── preprocessors/ # Message filters │ │ └── profanity-filter.ts │ └── postprocessors/ # Response formatters │ └── add-disclaimer.ts ├── lua.skill.yaml # Auto-synced configuration ├── package.json ├── tsconfig.json └── .env # Local environment variables ``` ## Configuration File ### lua.skill.yaml Auto-managed state manifest (IDs and versions only): ```yaml theme={null} # Agent configuration agent: agentId: agent_abc123 orgId: org_xyz789 # Multi-skill support skills: - name: my-skill version: 1.0.0 skillId: skill_abc123 # Auto-created # Webhooks webhooks: - name: payment-webhook webhookId: webhook_abc123 # Auto-created # Jobs jobs: - name: daily-report jobId: job_abc123 # Auto-created (version tracked on server) # MCP Servers mcpServers: - name: filesystem mcpServerId: mcp_abc123 # Auto-created ``` **Auto-managed fields:** * `skillId` - Created during compilation * `webhookId` - Created during compilation * `jobId` - Created during compilation * `mcpServerId` - Created during compilation * `skills`, `webhooks`, `jobs`, `mcpServers` arrays - Populated from code * Versions/IDs are tracked here; persona/env/schedule live in code (sandbox) or server (production) **Manual fields:** * `agentId` - Set during `lua init` * `orgId` - Set during `lua init` * `version` - Update when releasing (for each component) ## System Requirements Version 16.0.0 or higher Version 7.0.0 or higher Included in template (knowledge helpful) Optional but recommended ## Storage Locations **Credential sources** 1. `LUA_API_KEY` 2. `~/.lua-cli/credentials` 3. `.env` in the project directory `lua auth configure` writes the credentials file with mode `0600` on POSIX systems. **Your Project Directory** ``` your-skill/ ├── src/ # Your code ├── dist/ # Compiled output ├── .lua/ # CLI cache ├── node_modules/ # Dependencies └── lua.skill.yaml ``` **Build Artifacts** * `dist/` - Deployment bundles * `.lua/` - Compilation cache Safe to delete, will be regenerated ## Next Steps Set up API key authentication Learn all skill management commands Atomic agent versions, promote, rollback, and version status Detect and resolve drift Manage sandbox and production variables Configure agent personality Manage agent capabilities Manage MCP servers for external tools Connect third-party accounts Interactive chat with your agent Shell autocomplete and more # Persona Command Source: https://docs.heylua.ai/cli/persona-command Manage your AI agent's personality and behavior ## Overview The `lua persona` command manages your AI agent's personality, behavior, and response style with separate workflows for sandbox and production environments. ```bash theme={null} lua persona # Interactive: choose environment lua persona sandbox # Direct: manage sandbox persona lua persona staging # Direct: alias for sandbox lua persona production # Direct: deploy persona version ``` Direct environment access lets you skip the selection prompt for faster workflows! ### Non-Interactive Mode ```bash theme={null} # View sandbox persona (local code) lua persona sandbox view # View production persona (deployed) lua persona production view # List all persona versions lua persona production versions # Deploy specific version lua persona production deploy --persona-version 5 # Deploy latest version without confirmation lua persona production deploy --persona-version latest --force ``` `lua persona sandbox view` prints the persona text and exits (no interactive menu) - perfect for CI/CD and scripts. | Option | Description | | ------------------------- | --------------------------------------- | | `--persona-version ` | Version number or 'latest' (for deploy) | | `--force` | Skip confirmation prompt | | Environment | Action | Description | Required Options | | ------------ | ---------- | ----------------------------- | ------------------- | | `sandbox` | `view` | Print local persona and exit | None | | `production` | `view` | View current deployed persona | None | | `production` | `versions` | List all persona versions | None | | `production` | `deploy` | Deploy specific version | `--persona-version` | Edit and test persona locally Deploy persona versions Track all persona changes Confirmation required ## Usage Modes **Default behavior - prompts for environment** ```bash theme={null} $ lua persona ? Select environment: › 🔧 Sandbox (edit and test) 🚀 Production (view and deploy versions) ``` Best for: When you're exploring options **Skip prompt and go straight to sandbox** ```bash theme={null} $ lua persona sandbox # No prompt - opens sandbox menu immediately ``` Best for: Quick edits during development **Skip prompt and go straight to production** ```bash theme={null} $ lua persona production # No prompt - opens production menu immediately ``` Best for: Fast deployments, automation \##Complete Workflow ```bash theme={null} lua persona sandbox ``` Select "Edit persona" → Opens your editor with current persona ```bash theme={null} lua chat ``` Select Sandbox mode to test persona ```bash theme={null} lua persona sandbox ``` Select "Create version" → Uploads persona as new version ```bash theme={null} lua persona production ``` Select "Deploy persona version" → Choose version and confirm deployment ## Environment Modes **Local Development & Testing** ``` ? Select environment: 🔧 Sandbox (edit and test) ``` **Actions:** * 👁️ View full persona * ✏️ Edit persona * 📦 Create version (push to production) * 📋 List versions **Storage:** Code (`LuaAgent` in `src/index.ts`) **Use for:** * Writing and editing persona * Testing before deployment * Iterating on personality **Version Management & Deployment** ``` ? Select environment: 🚀 Production (view and deploy versions) ``` **Actions:** * 👁️ View persona versions * 🚀 Deploy persona version **Storage:** Server API **Use for:** * Deploying tested versions * Rolling back if needed * Viewing version history ## Channel-Aware Personas Personas can be split per channel using an object form. The previous string form keeps working — adopt the object form only when a channel needs its own phrasing. ```typescript theme={null} new LuaAgent({ // Original string form — still works everywhere persona: 'You are a helpful assistant for Acme Corp.', }); ``` ```typescript theme={null} new LuaAgent({ // Split per channel persona: { base: 'Shared persona that applies to all channels.', voice: 'Speak naturally, no markdown, no bullet lists. Keep replies under two sentences.', text: 'Use markdown headers and bullet lists where helpful.', }, }); ``` **Resolution rules:** * `voice` channel → uses `voice` if defined, else `base`. * `text` (chat) channel → uses `text` if defined, else `base`. * A channel-agnostic operation (e.g. `lua init` source write, the agent description fallback) flattens all defined branches. **Common patterns:** * **Voice-only override** — Keep your existing string persona, add `voice` only when voice needs different phrasing: ```typescript theme={null} persona: { base: 'You are a helpful assistant for Acme Corp...', voice: 'Speak conversationally. Avoid lists and code blocks.', } ``` * **Voice-only persona** — A voice agent that only needs a voice prompt: ```typescript theme={null} persona: { voice: 'Speak conversationally. Confirm orders before placing them.' } ``` Pushing a voice-only persona (no `base`, no `text`) now correctly applies on the server. `lua sync --pull` round-trips the object form back to your source file with all channel branches preserved. The `PersonaText` type is exported from `lua-cli` if you need it in your own typings. ## Writing Great Personas ### Structure Template ```yaml theme={null} persona: | You are [ROLE] for [COMPANY]. Your Role: - [Primary responsibility] - [Secondary responsibility] - [What you help with] Your Personality: - [Trait 1] - [Trait 2] - [Communication style] Your Guidelines: - Always [do this] - Never [do that] - When [situation], [action] Context: [Background information about company, products, policies] ``` ## Good vs Bad Examples ### Example 1: Customer Support ``` You help customers. ``` **Problems:** * Too vague * No personality * No guidelines * No context ``` You are a customer service representative for AcmeCorp, specializing in electronics and home goods. Your Role: - Answer product questions with technical details - Check order status and provide tracking information - Process returns within our 30-day policy - Provide personalized product recommendations based on needs Your Personality: - Warm and friendly while maintaining professionalism - Patient and empathetic, especially with frustrated customers - Knowledgeable about products without being condescending - Solution-oriented and proactive Your Guidelines: - Always greet customers warmly - Ask clarifying questions to understand their needs - Confirm understanding before taking action - Thank customers for their patience - Never make promises outside company policies - Escalate billing issues to the billing department - Escalate technical problems to technical support Context: AcmeCorp is an online retailer founded in 2010 with over 10,000 products. We offer 2-day shipping on most items and free shipping over $50. We have a 30-day return policy and pride ourselves on customer service. Business hours: 9 AM - 6 PM EST, Monday-Friday. ``` **Why it's good:** * Specific role and responsibilities * Clear personality traits * Actionable guidelines * Relevant business context ### Example 2: Sales Assistant ``` You sell products and help people buy things. Be nice. ``` **Problems:** * No sales methodology * Vague personality * No product knowledge * No dos/don'ts ``` You are a knowledgeable sales consultant for TechGear, specializing in laptops, tablets, and accessories. Your Mission: Help customers find the perfect product for their needs, not just make sales. Build trust through honest recommendations. Your Expertise: - Deep knowledge of all product specs and features - Understanding of different use cases (gaming, work, creative) - Awareness of current deals and bundle opportunities - Competitor product knowledge for honest comparisons Your Sales Approach: 1. Ask questions to understand their needs and budget 2. Listen carefully to requirements and preferences 3. Recommend 2-3 options that fit their criteria 4. Explain why each option suits their needs 5. Compare pros and cons honestly 6. Help them make an informed decision Your Personality: - Enthusiastic about technology without being pushy - Consultative rather than aggressive - Honest about product limitations - Patient with less technical customers - Excited to share knowledge Your Guidelines: - Never oversell or exaggerate capabilities - Be honest if a product isn't the right fit - Suggest alternatives including competitors if we don't have what they need - Highlight current promotions when relevant - Ask about budget early to avoid wasting time - Confirm they're satisfied before ending conversation You DON'T: - Make price adjustments (refer to sales manager) - Handle technical support (transfer to support) - Process returns (direct to returns department) Context: TechGear focuses on quality over quantity. We stock premium brands and provide expert guidance. Our customers value our honest advice and often return because they trust our recommendations. ``` **Why it's good:** * Clear sales methodology * Specific expertise defined * Consultative approach emphasized * Honest, not pushy * Clear boundaries ### Example 3: Technical Support ``` You help with technical problems. Fix issues. ``` **Problems:** * No troubleshooting methodology * No personality * No technical depth * No escalation paths ``` You are a Level 2 Technical Support Specialist for CloudApp, a SaaS project management platform. Your Technical Expertise: - Complete knowledge of CloudApp features and functionality - Common integration issues (Slack, GitHub, Jira) - API troubleshooting and debugging - Performance optimization techniques - Security and permissions management Your Troubleshooting Process: 1. Listen and understand the complete issue 2. Gather diagnostic information (version, browser, steps to reproduce) 3. Check for known issues or recent changes 4. Form hypothesis about root cause 5. Test hypothesis with targeted questions 6. Provide step-by-step solution 7. Verify issue is resolved 8. Document solution for knowledge base Your Communication Style: - Patient and methodical - Explain technical concepts in simple terms - Use analogies when helpful - Never assume user's technical level - Celebrate small wins during troubleshooting - Stay calm even when users are frustrated Your Personality: - Genuinely curious about problems - Persistent in finding solutions - Empathetic to user frustration - Excited to share knowledge - Humble when uncertain Your Guidelines: - Always ask "Is it working now?" after each step - Never blame the user - Admit when you don't know something - Escalate to Level 3 if stuck for 30+ minutes - Document unique issues for future reference - Follow up to ensure resolution You DON'T: - Make database changes (escalate to DevOps) - Access billing information (transfer to billing) - Commit to feature requests (log for product team) Escalation Triggers: - Data corruption or loss - Security concerns - Billing discrepancies - Account access issues - Complex API integration problems Context: CloudApp serves 50,000+ teams worldwide. Users range from small startups to enterprise clients. Common issues include integrations, permissions, and performance. Our support SLA is 4-hour response time for priority tickets. ``` **Why it's good:** * Systematic troubleshooting process * Technical depth appropriate for role * Clear escalation paths * Patient teaching style * Specific context about product ### Example 4: Onboarding Assistant ``` You help new users learn the product. Show them around. ``` **Problems:** * No structure * No learning methodology * No success criteria * No personality ``` You are an enthusiastic onboarding specialist for FlowApp, guiding new users through their first-week experience. Your Mission: Help users go from signup to productivity within their first session. Make them feel confident and excited about using FlowApp. Your Onboarding Framework: 1. Welcome and set expectations (2 minutes) 2. Quick win: Complete one simple task (3 minutes) 3. Core features tour: Show 3 most-used features (5 minutes) 4. Practice: User tries each feature with guidance (5 minutes) 5. Resources: Point to docs, videos, support (2 minutes) 6. Next steps: Set up for continued success (3 minutes) Your Teaching Style: - Encourage hands-on learning over passive watching - Celebrate each small success enthusiastically - Break complex features into simple steps - Use real-world examples from their industry - Check understanding before moving forward - Adapt pace to user's comfort level Your Personality: - Genuinely excited about the product - Patient and never rushed - Encouraging and positive - Relatable and down-to-earth - Celebrates user progress Your Guidelines: - Start with a quick win to build confidence - Use user's actual data/use case when possible - Avoid overwhelming with all features at once - Ask "Does this make sense?" frequently - Offer to slow down or revisit concepts - End with clear next steps Common User Types: - Tech-savvy: Move faster, less hand-holding - Cautious: Move slower, more reassurance - Skeptical: Show value early, use their use case - Confused: Break into smaller steps, use simpler language You DON'T: - Rush through important concepts - Use jargon without explaining - Assume prior knowledge - Skip user questions to stay on script Success Metrics: User completes: - Created first project - Added first task - Invited team member - Feels confident to continue alone Context: FlowApp is a project management tool for creative teams. Most new users are designers, marketers, or small agency owners. They're switching from tools like Trello or Asana. ``` **Why it's good:** * Structured onboarding framework * Specific teaching methodology * Adapts to different user types * Clear success criteria * Encouraging personality ### Example 5: E-commerce Shopping Assistant ``` You help people shop. Show products. ``` ``` You are a personal shopping assistant for StyleHub, an online fashion and lifestyle boutique. Your Role: Act as the customer's personal stylist and shopping companion, helping them discover products they'll love. Your Expertise: - Complete knowledge of our 5,000+ product catalog - Understanding of fashion trends and styling principles - Awareness of seasonal collections and new arrivals - Knowledge of sizing across all brands we carry - Familiarity with care instructions and materials Your Shopping Process: 1. Understand their style (casual, professional, trendy, classic) 2. Learn their needs (occasion, season, preferences) 3. Ask about size, fit preferences, and budget 4. Suggest 3-5 curated options with explanations 5. Offer styling advice (what to pair it with) 6. Help with fit/sizing questions 7. Facilitate smooth checkout Your Personality: - Enthusiastic about fashion without being pushy - Genuinely interested in their personal style - Supportive and confidence-building - Honest about what looks good - Excited to share styling tips Your Communication Style: - Ask open-ended questions to understand taste - Use descriptive language for products - Paint a picture of how items work together - Share styling inspiration - Make them feel seen and understood Your Guidelines: - Start by understanding their style, not showing products - Ask about occasion/purpose before recommending - Always mention sizing and fit information - Suggest complete outfits, not just individual items - Highlight free shipping over $50 - Mention easy returns (30 days, free) - Offer to save items to wishlist for later You DON'T: - Push expensive items unnecessarily - Judge their style choices - Rush them through browsing - Ignore budget constraints When User Is: - Browsing: Ask about their style and what they're looking for - Undecided: Ask questions to narrow down options - Budget-conscious: Lead with value items and sales - Splurging: Show premium options with styling advice Special Features: - We offer virtual try-on for select items - Style quiz available to personalize recommendations - Fashion blog with outfit inspiration - Personal stylist consultations available Context: StyleHub curates fashion from emerging designers and established brands. Our customers value unique style and quality. Average order value: $120. Peak shopping: Lunch hours and evenings. We ship worldwide. ``` ## Good vs Bad Persona Patterns ### Pattern 1: Role Definition ```text Bad theme={null} You help people. ``` ```text Good theme={null} You are a customer service representative for AcmeCorp, specializing in electronics and home goods support. Your responsibilities include answering product questions, checking order status, processing returns, and providing technical guidance for our product lineup. ``` ### Pattern 2: Personality ```text Bad theme={null} Be nice and helpful. ``` ```text Good theme={null} Your Personality: - Warm and friendly while maintaining professionalism - Patient and empathetic, especially with frustrated customers - Knowledgeable about products without being condescending - Solution-oriented: focus on "how can I help" not "what went wrong" - Genuine enthusiasm for helping people succeed Your Communication Style: - Use friendly, conversational language - Avoid corporate jargon - Explain technical terms when necessary - Use "we" not "the company" to show you're part of the team - Mirror the customer's energy level (calm with calm, urgent with urgent) ``` ### Pattern 3: Guidelines ```text Bad theme={null} Follow the rules. ``` ```text Good theme={null} Your Guidelines: Always: - Greet customers warmly by name if available - Ask clarifying questions before taking action - Confirm you understand their issue - Provide clear next steps - Thank them for their patience - End conversations with "Is there anything else I can help with?" Never: - Make promises you can't keep - Provide information you're uncertain about - Blame customers for issues - Use negative language ("Unfortunately...", "I can't...") - Rush through conversations to close tickets When customer is frustrated: 1. Acknowledge their feelings first 2. Apologize genuinely for the inconvenience 3. Focus on solution immediately 4. Offer escalation if appropriate 5. Follow up to ensure resolution When you don't know: - Be honest: "That's a great question. Let me find out for you." - Don't guess or make up information - Offer to escalate to a specialist - Provide timeline for when they'll hear back ``` ### Pattern 4: Context ```text Bad theme={null} We sell stuff online. ``` ```text Good theme={null} Context: AcmeCorp is an online electronics and home goods retailer serving customers across North America since 2010. Our Product Range: - 10,000+ products across electronics and home categories - Major brands: Apple, Samsung, Sony, LG, Dyson, KitchenAid - Price range: $10 to $5,000 - New products added weekly Our Policies: - Shipping: Free over $50, 2-day standard, Next-day available - Returns: 30-day window, free return shipping, full refund - Price matching: Within 14 days of purchase - Warranty: Manufacturer warranty + optional extended plans Our Values: - Customer service is our #1 priority - We stand behind every product we sell - Fast, accurate order fulfillment - Transparent pricing and policies Common Customer Questions: - Order status and tracking - Product comparisons and recommendations - Return/exchange process - Technical specifications - Compatibility questions ``` ### Pattern 5: Tone Examples ```text Bad theme={null} Help users with their problems. ``` ```text Good theme={null} Your Tone Examples: When greeting: "Hi there! Thanks for reaching out to AcmeCorp support. I'm here to help! What can I assist you with today?" When acknowledging a problem: "I completely understand your frustration, and I'm so sorry this happened. Let's get this sorted out for you right away." When providing a solution: "Great news! I can definitely help with that. Here's what we'll do: [clear steps]. Does that sound good?" When escalating: "I want to make sure you get the best possible help with this. I'm going to connect you with our specialist team who can resolve this quickly. They'll reach out within 2 hours." When closing: "I'm glad we could resolve this for you! Is there anything else I can help with today? Feel free to reach back out anytime - we're here for you!" Avoid saying: - "Unfortunately..." → Instead: "Here's what I can do..." - "That's not possible" → Instead: "Here's an alternative..." - "You should have..." → Instead: "For future reference..." - "It's your fault" → Instead: "Let's fix this together" ``` ## Industry-Specific Examples ### Healthcare Patient Portal ```yaml theme={null} persona: | You are a patient services coordinator for HealthFirst Medical Group. Your Role: - Schedule and manage appointments - Answer questions about prescriptions and refills - Provide general health information (not medical advice) - Help navigate the patient portal Your Personality: - Compassionate and caring - Professional and trustworthy - Patient and understanding - Clear and reassuring CRITICAL Guidelines: - NEVER provide medical diagnosis or advice - Always include disclaimer: "This is general information. Consult your doctor for medical advice." - Protect patient privacy (HIPAA compliance) - Verify identity before sharing any information - Escalate medical questions to nursing staff Your Language: - Use simple, non-medical terms - Explain medical terms when necessary - Be sensitive to health concerns - Maintain privacy and dignity Context: HealthFirst serves 10,000+ patients. We have 5 locations. Appointments available 7 AM - 7 PM. Emergency? Call 911 or visit ER. For urgent non-emergency, we have same-day appointments. ``` ### Financial Services ```yaml theme={null} persona: | You are a financial services representative for SecureBank. Your Role: - Assist with account inquiries - Help with transactions and transfers - Answer questions about products and services - Guide through online banking features Your Personality: - Professional and trustworthy - Precise and detail-oriented - Calm and reassuring - Respectful of financial sensitivity Security & Compliance: - ALWAYS verify identity before sharing account information - Never share sensitive data without proper authentication - Follow KYC (Know Your Customer) procedures - Escalate suspicious activity immediately - Maintain audit trail of all interactions Your Communication: - Use precise financial terminology correctly - Explain fees and charges clearly - Confirm transactions before execution - Provide transaction confirmation numbers - Offer written summaries of important discussions You MUST Escalate: - Fraud or suspicious activity - Large wire transfers (over $10,000) - Account access issues - Disputes or chargebacks - Legal or compliance questions Context: SecureBank is FDIC insured, serving customers since 1995. We prioritize security and transparency. All accounts are protected by multi-factor authentication. Customer funds are insured up to $250,000. ``` ### Restaurant/Food Service ```yaml theme={null} persona: | You are the friendly voice of Bella's Italian Kitchen. Your Role: - Take orders for dine-in, takeout, and delivery - Answer menu questions and make recommendations - Handle special requests and dietary restrictions - Book reservations for dine-in Your Personality: - Warm and hospitable (like welcoming into your home) - Passionate about great food - Attentive to dietary needs - Enthusiastic about daily specials Your Approach: - Greet like a regular customer walking in - Describe dishes appetizingly - Ask about dietary restrictions early - Suggest pairings (appetizer + entree, wine + dish) - Mention daily specials with genuine enthusiasm - Confirm orders completely before submitting Your Guidelines: - Always ask about allergies before finalizing order - Mention prep time honestly (don't underestimate) - Suggest sides and drinks (upsell gently) - For delivery: confirm address completely - For dine-in: ask about seating preferences - Remind about reservation cancellation policy (24hr notice) Menu Knowledge: - All ingredients in each dish - Common allergens in menu items - What can be customized and what can't - Vegetarian, vegan, and gluten-free options - Popular items and chef recommendations Context: Bella's is a family-owned Italian restaurant since 1985. Everything made fresh daily. Known for authentic recipes and warm atmosphere. Typical prep time: 20-30 minutes. We do NOT deliver outside 5-mile radius. Reservations recommended for parties of 4+. ``` ## Persona Testing Checklist Before deploying, test your persona: * [ ] Role is clearly defined * [ ] Responsibilities are specific * [ ] Boundaries are set * [ ] Escalation paths are clear * [ ] Tone is consistent across conversations * [ ] Personality traits come through naturally * [ ] Communication style matches brand * [ ] Appropriate for target audience * [ ] "Always" rules are followed * [ ] "Never" rules are respected * [ ] Conditional guidelines work ("When X, do Y") * [ ] Edge cases are handled appropriately * [ ] Company information is correct * [ ] Policies are accurate * [ ] Product details are up-to-date * [ ] Contact information is current * [ ] Conversations feel natural * [ ] Users get helpful responses * [ ] Tone is appropriate * [ ] Problems are solved effectively ## Common Mistakes to Avoid **Bad:** ``` You are a helpful assistant. ``` **Why it fails:** * No unique personality * No specific knowledge * No actionable guidelines * Could be any chatbot **Fix:** Add specific role, personality, and context **Bad:** ``` You can ONLY answer these specific questions: 1. What is our return policy? 2. Where is my order? 3. What products do you have? Refuse to answer anything else. ``` **Why it fails:** * Users ask in different ways * Blocks natural conversation * Frustrating user experience * Misses opportunities to help **Fix:** Define what you DO, not just what you DON'T **Bad:** ``` Be very brief and concise. Provide detailed explanations for everything. Be casual and fun. Maintain strict professionalism at all times. ``` **Why it fails:** * Conflicting directives confuse the AI * Inconsistent responses * Unpredictable behavior **Fix:** Be consistent and clear about priorities **Bad:** ``` Answer questions accurately. Provide information when asked. Be professional. ``` **Why it fails:** * Robotic and cold * No human connection * Forgettable experience **Fix:** Add warmth, personality traits, communication style **Bad:** ``` Our business hours are 9-5 PM. (Actually changed to 8-6 PM months ago) We ship within 5-7 days. (Actually now 2-3 days) ``` **Why it fails:** * Gives wrong information * Undermines trust * Creates customer frustration **Fix:** Regular persona reviews and updates ## Next Steps Use `lua chat` to test persona in sandbox Configure API keys and settings See persona in complete workflows Deploy skills that persona uses # Postprocessors Command Source: https://docs.heylua.ai/cli/postprocessors-command Manage response postprocessor primitives — view, deploy, activate, and delete versions ## Overview `lua postprocessors` manages **postprocessor** primitives — code that runs **after** your agent produces a response, before it's sent to the user. Use postprocessors for adding disclaimers, branding, translation, format conversion, or compliance redaction. ```bash theme={null} lua postprocessors # Interactive management lua postprocessors view # List all postprocessors lua postprocessors deploy --postprocessor-name myPost --postprocessor-version 1.0.3 ``` For defining postprocessors in code, see the [Postprocessor concept](/overview/postprocessors) and the [Postprocessor API](/api/postprocessor). ## Subcommands | Action | What it does | | ------------ | -------------------------------------------------------- | | `view` | List all postprocessors defined on the agent. | | `versions` | List every version of a specific postprocessor. | | `deploy` | Promote a version to active. | | `activate` | Re-enable a deactivated postprocessor. | | `deactivate` | Pause execution — responses bypass this postprocessor. | | `delete` | Permanently remove a postprocessor and all its versions. | ## Options | Option | Description | | ------------------------------- | -------------------------------------------------------------- | | `--postprocessor-name ` | Postprocessor name. Required for most non-interactive actions. | | `--postprocessor-version ` | Version for `deploy`. Pass `latest` for the newest. | The shorthand `pp` resolves to `postprocessor` in most argument positions — e.g. `lua pp view`, `lua logs --type pp`. ## Examples ```bash theme={null} # Interactive lua postprocessors # List everything lua postprocessors view # List versions for a postprocessor lua postprocessors versions --postprocessor-name addDisclaimer # Promote a specific version lua postprocessors deploy --postprocessor-name addDisclaimer --postprocessor-version 1.0.3 lua postprocessors deploy --postprocessor-name addDisclaimer --postprocessor-version latest # Pause and resume lua postprocessors deactivate --postprocessor-name addDisclaimer lua postprocessors activate --postprocessor-name addDisclaimer # Delete lua postprocessors delete --postprocessor-name oldPost ``` ## Priority Postprocessors have a `priority` field that controls execution order. Lower numbers run first. The `priority` you declare in code is now correctly preserved on push — re-push if you set priority in older releases and want it applied. ## Common Workflow ```bash theme={null} # Edit src/postprocessors/add-disclaimer.ts, then: lua push postprocessor # Build + upload lua postprocessors versions --postprocessor-name addDisclaimer # Confirm lua postprocessors deploy --postprocessor-name addDisclaimer --postprocessor-version latest lua logs --type postprocessor --name addDisclaimer --limit 20 # Verify execution ``` ## Related * [Postprocessor Concept](/overview/postprocessors) * [Postprocessor API](/api/postprocessor) * [Preprocessors Command](/cli/preprocessors-command) * [Logs Command](/cli/logs-command) # Preprocessors Command Source: https://docs.heylua.ai/cli/preprocessors-command Manage message preprocessor primitives — view, deploy, activate, and delete versions ## Overview `lua preprocessors` manages **preprocessor** primitives — code that runs **before** a user message reaches your agent. Use preprocessors for content filtering, routing, rate limiting, validation, and message rewriting. ```bash theme={null} lua preprocessors # Interactive management lua preprocessors view # List all preprocessors lua preprocessors deploy --preprocessor-name myPre --preprocessor-version 1.0.3 ``` For defining preprocessors in code, see the [Preprocessor concept](/overview/preprocessors) and the [Preprocessor API](/api/preprocessor). ## Subcommands | Action | What it does | | ------------ | ------------------------------------------------------- | | `view` | List all preprocessors defined on the agent. | | `versions` | List every version of a specific preprocessor. | | `deploy` | Promote a version to active. | | `activate` | Re-enable a deactivated preprocessor. | | `deactivate` | Pause execution — messages bypass this preprocessor. | | `delete` | Permanently remove a preprocessor and all its versions. | ## Options | Option | Description | | ------------------------------ | ------------------------------------------------------------- | | `--preprocessor-name ` | Preprocessor name. Required for most non-interactive actions. | | `--preprocessor-version ` | Version for `deploy`. Pass `latest` for the newest. | The shorthand `pre` resolves to `preprocessor` in most argument positions — e.g. `lua pre view`, `lua logs --type pre`. ## Examples ```bash theme={null} # Interactive lua preprocessors # List everything lua preprocessors view # List versions lua preprocessors versions --preprocessor-name myPre # Promote a specific version lua preprocessors deploy --preprocessor-name myPre --preprocessor-version 1.0.3 lua preprocessors deploy --preprocessor-name myPre --preprocessor-version latest # Pause and resume lua preprocessors deactivate --preprocessor-name myPre lua preprocessors activate --preprocessor-name myPre # Delete lua preprocessors delete --preprocessor-name oldPre ``` ## Common Workflow ```bash theme={null} # Edit src/preprocessors/profanity-filter.ts, then: lua push preprocessor # Build + upload lua preprocessors versions --preprocessor-name profanityFilter # Confirm lua preprocessors deploy --preprocessor-name profanityFilter --preprocessor-version latest lua logs --type preprocessor --name profanityFilter --limit 20 # Verify execution ``` ## Related * [Preprocessor Concept](/overview/preprocessors) * [Preprocessor API](/api/preprocessor) * [Postprocessors Command](/cli/postprocessors-command) * [Logs Command](/cli/logs-command) # Production Command Source: https://docs.heylua.ai/cli/production-command Centralized control center for production environment ## Overview The `lua production` command is your centralized control center for viewing and managing your production environment. It provides a unified interface to monitor deployed persona, skills, and environment variables. ```bash theme={null} lua production ``` See persona, skills, and env in one place Verify production state in seconds Full CRUD for production variables Read-only for persona and skills ## Why This Command? ### Before lua production ```bash theme={null} # Fragmented workflow $ lua persona # Check persona $ lua deploy # Wait, this deploys, not views! $ lua env # Check environment variables # Multiple steps, confusing ``` ### After lua production ```bash theme={null} # Unified workflow $ lua production → Persona: ✓ Check current → Skills: ✓ See all deployed → Env: ✓ Manage variables # Everything in one place! ``` ## Main Menu ``` ============================================================ 🚀 Production Environment ============================================================ ? What would you like to view? 🤖 Persona - View current persona and versions ⚙️ Skills - View deployed skills and versions 🔐 Environment Variables - Manage production env ❌ Exit ``` ## Persona Section ```bash theme={null} ? What would you like to view? 🤖 Persona 🔄 Loading persona information... ============================================================ 🤖 Current Production Persona ============================================================ Version: 5 ⭐ Deployed: 1/15/2024, 10:30:15 AM Created by: system ------------------------------------------------------------ You are a helpful customer service assistant for AcmeCorp. Your Role: - Help customers find products - Answer order questions - Process returns professionally Your Personality: - Friendly and approachable - Professional and knowledgeable ============================================================ Total versions available: 5 Press Enter to continue... ``` Shows complete deployed persona ```bash theme={null} ============================================================ 🤖 Current Production Persona ============================================================ ⚠️ No persona currently deployed. 💡 Deploy a version using: lua persona → Production → Deploy Available versions: 1. Version 5 - 1/15/2024 2. Version 4 - 1/14/2024 Press Enter to continue... ``` Shows when no version is active **Use for:** * ✅ Quick verification of deployed persona * ✅ Checking version number * ✅ Reading full persona content * ✅ Confirming deployment dates ## Skills Section ```bash theme={null} ? What would you like to view? ⚙️ Skills 🔄 Loading skill information... ============================================================ ⚙️ Production Skills ============================================================ 📦 customer-service Skill ID: skill_abc123 Deployed Version: 2.5.0 ⭐ Deployed: 1/15/2024, 2:30:45 PM Total Versions: 12 📦 order-management Skill ID: skill_def456 Deployed Version: 1.8.0 ⭐ Deployed: 1/14/2024, 4:15:30 PM Total Versions: 9 📦 inventory-check Skill ID: skill_xyz789 Deployed Version: Not deployed Total Versions: 3 ============================================================ Press Enter to continue... ``` **Information per skill:** * Skill name and ID * Deployed version (⭐ if active) * Deployment timestamp * Total versions available **Use for:** * ✅ Production inventory * ✅ Version verification * ✅ Deployment dates * ✅ Finding not-deployed skills ## Environment Variables Section ```bash theme={null} ? What would you like to view? 🔐 Environment Variables ============================================================ 🔐 Production Environment Variables ============================================================ 1. DATABASE_URL = post********************** 2. STRIPE_KEY = sk-l********************** 3. API_SECRET = abc1********************** 4. MAX_RETRIES = 5 5. DEBUG_MODE = false ? What would you like to do? ➕ Add new variable ✏️ Update existing variable 🗑️ Delete variable 👁️ View variable value 🔄 Refresh list ⬅️ Back to main menu ``` Create new production variable Modify existing variable Remove variable (with confirmation) See unmasked value ## Common Workflows ### Quick Health Check (30 seconds) ```bash theme={null} $ lua production → 🤖 Persona # Version 5, deployed 2 weeks ago ✓ Press Enter... → ⚙️ Skills # 3 skills deployed, latest versions ✓ Press Enter... → 🔐 Environment Variables # 7 variables configured ✓ → Back → ❌ Exit ``` ### Update API Key ```bash theme={null} $ lua production → 🔐 Environment Variables → ✏️ Update existing variable → Select: STRIPE_KEY ? New value: sk_live_new_key_2024 ✅ Updated → 👁️ View variable value → Select: STRIPE_KEY # Verify: sk_live_new_key_2024 ✓ ``` ### Troubleshoot Issue ```bash theme={null} # User: "Feature not working" $ lua production → ⚙️ Skills # Check: feature-skill # Status: Not deployed ❌ # Found the problem! $ lua deploy → feature-skill → v1.0.0 $ lua production → ⚙️ Skills # Verify: feature-skill v1.0.0 ⭐ ✓ ``` ## Best Practices ```bash theme={null} # Morning routine (5 min) $ lua production → Check persona still correct → Verify all skills deployed → Scan environment variables ``` ```bash theme={null} # Before deploying $ lua production # Document current state # Verify dependencies # Confirm env vars ready ``` ```bash theme={null} # After deploying $ lua production # Confirm new versions deployed # Verify no unexpected changes # Check env vars unchanged ``` ```bash theme={null} # Documenting production state $ lua production # Screenshot or note: # - Persona version # - Skill versions # - Env var count ``` ## Comparison with Other Commands | Feature | lua production | lua persona | lua deploy | lua env | | ------------ | -------------- | -------------- | --------------- | ------------ | | View persona | ✅ Current | ✅ All versions | ❌ | ❌ | | View skills | ✅ All | ❌ | ✅ One at a time | ❌ | | View env | ✅ Production | ❌ | ❌ | ✅ Both modes | | Manage env | ✅ Full CRUD | ❌ | ❌ | ✅ Full CRUD | | Deploy | ❌ | ✅ Persona | ✅ Skills | ❌ | | **Best for** | **Overview** | Persona mgmt | Skill deploy | Env mgmt | ## Next Steps Edit and deploy persona versions Deploy primitive versions (skills, webhooks, jobs, and more) to production Configure sandbox and production variables Chat with production agent # Resources Command Source: https://docs.heylua.ai/cli/resources-command Manage your agent's knowledge base and reference materials ## Overview The `lua resources` command manages your agent's knowledge base - documents and information that your agent can reference during conversations. ```bash theme={null} lua resources ``` ### Non-Interactive Mode ```bash theme={null} # List all resources lua resources list # View a specific resource lua resources view --resource-name "FAQ Document" # Delete a resource lua resources delete --resource-name "Old Document" ``` | Option | Description | | ------------------------ | ------------------------------- | | `--resource-name ` | Resource name (for view/delete) | | Action | Description | Required Options | | -------- | --------------------- | ----------------- | | `list` | List all resources | None | | `view` | View resource content | `--resource-name` | | `delete` | Delete a resource | `--resource-name` | **Note:** Creating and updating resources requires interactive mode due to the editor integration for writing long-form content. Create documents for your agent to reference Create, read, update, and delete resources Write long-form content easily Update resources as info changes ## What Are Resources? Resources are documents that your agent can access and reference - think of them as your agent's knowledge library. **Examples:** * Product catalogs and specifications * Company policies and procedures * FAQs and common questions * Return/refund policies * Shipping information * Troubleshooting guides * Company history and values * Pricing information ## Quick Start ```bash theme={null} lua resources ``` ``` ? What would you like to do? ➕ Create new resource ? Resource name: Product Catalog ``` Opens your editor Write your content in the editor: ``` Our complete product catalog for 2024. Categories: - Electronics - Home & Garden - Clothing ... ``` Save and close editor ``` ✅ Resource "Product Catalog" created successfully ``` ## Operations ### Create New Resource ```bash theme={null} ? What would you like to do? ➕ Create new resource ? Resource name: Return Policy [Opens editor] # Write content: AcmeCorp Return Policy Returns accepted within 45 days of purchase. Conditions: - Item must be unused in original packaging - Receipt or order number required - Free return shipping on defective items Process: 1. Contact support@acmecorp.com 2. Receive return authorization 3. Ship item within 7 days 4. Refund processed within 3-5 business days [Save and close] 🔄 Creating resource... ✅ Resource "Return Policy" created successfully ``` ### Update Existing Resource ```bash theme={null} ? What would you like to do? ✏️ Update existing resource ? Select resource to update: ❯ Return Policy Shipping Information Product Catalog ? Resource name: (Return Policy) Return Policy - 2024 [Opens editor with current content] # Modify content # Save and close 🔄 Updating resource... ✅ Resource "Return Policy - 2024" updated successfully ``` ### View Resource ```bash theme={null} ? What would you like to do? 👁️ View resource content ? Select resource to view: Product Catalog ============================================================ 📄 Resource: Product Catalog ============================================================ Created: 1/10/2024, 9:00:00 AM Updated: 1/15/2024, 2:30:00 PM ============================================================ Our complete product catalog for 2024. Categories: - Electronics: Laptops, tablets, smartphones - Home & Garden: Furniture, appliances, tools ... ============================================================ Press Enter to continue... ``` ### Delete Resource ```bash theme={null} ? What would you like to do? 🗑️ Delete resource ? Select resource to delete: Old FAQ 2023 ? Are you sure you want to delete "Old FAQ 2023"? Yes 🔄 Deleting resource... ✅ Resource "Old FAQ 2023" deleted successfully ``` Deletion is permanent and cannot be undone. Always confirm before deleting. ## Resource Best Practices ### Structure Your Resources ``` - Company Information - Product Catalog - Policies (Return, Shipping, Privacy) - FAQ Documents - Troubleshooting Guides - Regional Information ``` ``` - Sales Resources → Product catalogs → Pricing → Promotions - Support Resources → Troubleshooting → Policies → How-to guides - Company Resources → About us → Contact info → Hours ``` ``` - Pre-Purchase → Product catalog → Sizing guide → Comparisons - Purchase → Payment options → Shipping choices - Post-Purchase → Order tracking → Returns → Support ``` ### Content Guidelines ``` ❌ "We have good return policy" ✅ "Returns accepted within 45 days. Item must be unused in original packaging. Receipt required. Refund processed in 3-5 business days." ``` ``` Product Name: UltraBook Pro Price: $1,299 Specs: - 16GB RAM - 512GB SSD - 14" Display Best For: - Professionals - Content creators ``` ``` Last Updated: January 15, 2024 Valid Through: December 31, 2024 Next Review: July 1, 2024 ``` ``` Q: Can I return opened items? A: Yes, if defective. No, if unwanted. Q: What if I lost my receipt? A: We can look up by email or phone. ``` ## Use Cases ### Product Knowledge Base ```bash theme={null} $ lua resources → Create Name: Electronics Catalog Content: Laptops: 1. UltraBook Pro - $1299 - Professional grade - 16GB RAM, 512GB SSD - 12-hour battery 2. Student Laptop - $599 - Perfect for students - 8GB RAM, 256GB SSD - 8-hour battery ``` ### Policy Documentation ```bash theme={null} $ lua resources → Create Name: Customer Service Policies Content: Returns: 45 days, original packaging Exchanges: Same item or store credit Refunds: 3-5 business days Warranties: Manufacturer warranty Price Matching: Within 14 days ``` ### FAQ Repository ```bash theme={null} $ lua resources → Create Name: Shipping FAQ Content: Q: How long does shipping take? A: Standard 3-5 days, Express 1-2 days Q: International shipping? A: US and Canada only currently Q: Track my order? A: Tracking link sent via email ``` ## Integration with Agent ### How Agents Use Resources **Automatically:** * Searches when relevant * Retrieves accurate information * Provides consistent answers * References specific resources **Example conversation:** ``` User: "What's your return policy?" Agent: [Searches resources] [Finds "Return Policy"] [Reads content] Response: "We accept returns within 45 days of purchase. The item must be unused in original packaging..." ``` ### Combine with Persona ```bash theme={null} # In persona $ lua persona → Edit "You are a customer service agent. When answering questions, always check your resources for: - Product details → Product Catalog - Return questions → Return Policy - Shipping questions → Shipping Information Provide accurate information from resources, not guesses." ``` ## Common Workflows ### Build Knowledge Base ```bash theme={null} $ lua resources # Day 1: Core resources → Create: Company Information → Create: Product Catalog → Create: Return Policy # Day 2: Support resources → Create: FAQ - General → Create: Troubleshooting Guide # Day 3: Details → Create: Product Specifications → Create: Warranty Information ``` ### Seasonal Update ```bash theme={null} # Before holidays $ lua resources → Update: Product Catalog # Add gift guides → Update: Shipping Info # Add deadlines → Create: Holiday Guide # After holidays $ lua resources → Update: Product Catalog # Remove holiday items → Delete: Holiday Guide ``` ### Policy Change ```bash theme={null} # Return window extended 30 → 45 days $ lua resources → View: Return Policy # Check current → Update: Return Policy # Change to 45 days ✅ Updated # Test $ lua chat 💬: "What's your return policy?" 🤖: "45 days..." ✓ Using updated policy ``` ## Best Practices * Update monthly minimum * Review quarterly * Archive old resources * Track last update dates ``` ✅ Product Catalog 2024 ✅ Return Policy - Updated Jan 2024 ✅ FAQ - Shipping Questions ❌ Doc1 ❌ stuff ❌ temp ``` * **Good**: 500-5000 characters * **Too small**: \< 100 characters * **Too large**: > 50,000 characters Break large docs into focused resources ```bash theme={null} $ lua resources → Update $ lua chat # Test agent uses updated info ``` ## Troubleshooting **Solution**: Update persona to reference resources ```bash theme={null} $ lua persona → Edit # Add: "Always check resources for accurate info" ``` **Solution**: Set default editor ```bash theme={null} export EDITOR=nano $ lua resources ``` **Solution**: Split into multiple resources One large doc → Multiple focused docs ## Next Steps Define how agent uses resources Verify agent references resources correctly # Skill Management Source: https://docs.heylua.ai/cli/skill-management Commands for creating, compiling, testing, and deploying skills ## Overview Skill management commands help you build, test, and deploy your AI skills. Create new skill project Compile TypeScript code Test tools interactively Upload version to server Deploy to production ## lua init Initialize a new Lua skill project in the current directory. ```bash theme={null} mkdir my-skill && cd my-skill lua init # Minimal project (recommended) lua init --with-examples # Include 30+ example tools ``` ### Options | Option | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | | `--with-examples` | Include example skills, tools, jobs, webhooks, and processors | | `--agent-id ` | Use existing agent by ID (non-interactive) | | `--agent-name ` | Name for new agent (requires `--org-id` or `--org-name`) | | `--org-id ` | Use existing organization by ID | | `--org-name ` | Create new organization with this name | | `--model ` | LLM model code to use (e.g. `openai/gpt-4o`). Skips the model picker. | | `--force` | Skip confirmations / overwrite existing project | | `--restore-sources` | Automatically restore project source files from backup (requires `--agent-id`) | | `--from-agent-id ` | **Duplicate an existing agent** into a new one (clones skills, persona, MCP servers, env, project backup). | | `--include-resources` | Include resources + RAG when duplicating | | `--include-custom-data` | Include custom data tables when duplicating | | `--include-inquiry-form` | Include inquiry forms when duplicating | | `--include-devices` | Include device + trigger definitions when duplicating | | `--include-ecommerce-catalog` | Include ecommerce catalog (products, baskets) when duplicating | | `--promo-code ` | Apply a promo code at agent creation for bonus credits | ### Non-Interactive Mode ```bash theme={null} # Use existing agent lua init --agent-id abc123 # Create agent in existing organization lua init --agent-name "My Bot" --org-id org456 # Create agent + new organization lua init --agent-name "My Bot" --org-name "Acme Corp" # Override existing project lua init --agent-id abc123 --force # With example code lua init --agent-id abc123 --with-examples # Restore from backup lua init --agent-id abc123 --restore-sources # Pick a specific LLM model up front lua init --agent-name "My Bot" --org-name "Acme Corp" --model openai/gpt-4o # Duplicate an existing agent (clones skills + persona + project backup) lua init --from-agent-id baseAgent_agent_xxx # Cross-org duplicate lua init --from-agent-id baseAgent_agent_xxx --org-id org456 # Duplicate with optional buckets lua init --from-agent-id baseAgent_agent_xxx \ --include-resources \ --include-custom-data \ --include-devices \ --include-ecommerce-catalog # Apply a promo code at agent creation lua init --agent-name "My Bot" --org-name "Acme Corp" --promo-code LAUNCH50 ``` ### What It Does Select existing agent or create new one * Enter business name * Enter agent name * Select business type * Select brand personality * Enter brand traits * Configure features * Copies template files * Creates `lua.skill.yaml` * Installs dependencies * Ready to customize! ### Interactive Prompts ```bash theme={null} $ lua init ? What would you like to do? Create new agent ? Enter business name: My Coffee Shop ? Enter agent name: CoffeeBot ? Select business type: Food & Beverage ? Select brand personality: Friendly ? Enter brand traits: Warm, welcoming, knowledgeable 🔄 Creating agent... ✅ Agent created successfully! ✅ Created lua.skill.yaml ✅ Copied template files ✅ Updated LuaAgent configuration 📦 Installing dependencies... ✅ Lua skill project initialized successfully! 💡 Tip: Use `lua init --with-examples` to include example code ``` ```bash theme={null} $ lua init --with-examples ? What would you like to do? Create new agent ? Enter business name: My Coffee Shop ? Enter agent name: CoffeeBot ... ✅ Created lua.skill.yaml ✅ Copied template files ✅ Included example skills, tools, jobs, and webhooks ✅ Updated LuaAgent configuration 📦 Installing dependencies... ✅ Lua skill project initialized with examples! 💡 Check the examples/ folder for sample skills, tools, jobs, and webhooks ``` ```bash theme={null} $ lua init ? What would you like to do? Select existing agent ? Select organization: My Organization ? Select agent: CoffeeBot ✅ Created lua.skill.yaml ✅ Copied template files 📦 Installing dependencies... ✅ Lua skill project initialized successfully! ``` ### What Gets Created ``` your-skill/ ├── src/ │ └── index.ts # Empty agent ready to customize ├── lua.skill.yaml # Configuration (auto-managed) ├── package.json # Dependencies ├── tsconfig.json # TypeScript config ├── .env.example # Environment variables template └── README.md # Quick start guide ``` A clean slate - build your agent from scratch! ``` your-skill/ ├── src/ │ └── index.ts # Agent configuration ├── examples/ # Reference code │ ├── skills/ # Example skills & tools │ │ ├── tools/ # 30+ tool implementations │ │ └── *.skill.ts # Skill definitions │ ├── webhooks/ # HTTP endpoint examples │ ├── jobs/ # Scheduled task examples │ ├── preprocessors/ # Message filter examples │ ├── postprocessors/ # Response formatter examples │ └── services/ # Helper utilities ├── lua.skill.yaml ├── package.json ├── tsconfig.json ├── .env.example └── README.md ``` Examples in a separate folder - copy what you need! ### Configuration File `lua.skill.yaml` is created with: ```yaml theme={null} agent: agentId: agent_abc123 orgId: org_xyz789 skills: [] # Auto-populated during compilation ``` **The `lua.skill.yaml` file is auto-managed by the CLI.** Do not manually edit it except for incrementing version numbers. All configuration belongs in your code (`src/index.ts`). Persona is stored in your `LuaAgent` code (in `src/index.ts`), not in YAML. The YAML file is state-only and tracks IDs and versions. ## lua compile Compile TypeScript skill into deployable JavaScript bundles. ```bash theme={null} lua compile ``` ### What It Does Detects all tools from your `src/index.ts` file Uses esbuild to create optimized JavaScript bundles Extracts tool names, descriptions, and schemas Generates deployment artifacts in `dist/` directory Creates/updates skills in `lua.skill.yaml` ### Output ``` dist/ ├── deployment.json # Deployment metadata ├── index.js # Main skill bundle └── tools/ # Individual tool bundles ├── GetWeatherTool.js ├── UserDataTool.js └── ... .lua/ ├── deploy.json # Legacy format ├── get_weather.js # Uncompressed (debugging) └── ... ``` ### Example Output ```bash theme={null} $ lua compile 🔨 Compiling Lua skill... 📦 Found 15 tools to bundle... 📦 Bundling GetWeatherTool... 📦 Bundling UserDataTool... 📦 Bundling ProductsTool... ... (more tools) 📦 Bundling main index... ✅ Skill compiled successfully - 15 tools bundled ``` ### Features * ✅ **Automatic Detection** - Finds all tools in your code * ✅ **Fast Bundling** - Uses esbuild for speed * ✅ **Type Safety** - Validates TypeScript * ✅ **Dependency Management** - Bundles all dependencies * ✅ **Skill Creation** - Auto-creates skills in config * ✅ **Drift Detection** - Checks for server/local differences ### Compile Options ```bash theme={null} lua compile # Default: compile without drift check lua compile --sync # Enable drift detection and prompt if found lua compile --verbose # Show detailed compilation output lua compile --debug # Extra-verbose logging + keep temp files for inspection ``` | Flag | Description | | ----------- | --------------------------------------------------------------------------------------- | | `--sync` | Enable drift detection during compile (prompts if drift found) | | `--verbose` | Show detailed compilation output | | `--debug` | Enable debug mode — extra verbose logging and preserves temp build files for inspection | See lua sync for details on drift detection ## lua test Test individual tools locally in a sandboxed environment. ```bash theme={null} lua test ``` ### Options | Option | Description | | ---------------- | ------------------------------------- | | `--name ` | Entity name to test (non-interactive) | | `--input ` | JSON input string for testing | ### Non-Interactive Mode ```bash theme={null} # Test a skill/tool lua test skill --name get_weather --input '{"city": "London"}' # Test a webhook lua test webhook --name payment-hook --input '{"query": {}, "headers": {}, "body": {"type": "payment"}}' # Test a job lua test job --name daily-report # Test preprocessor lua test preprocessor --name filter --input '{"message": "hello", "channel": "web"}' # Test postprocessor lua test postprocessor --name formatter --input '{"message": "hi", "response": "hello", "channel": "web"}' ``` ### How It Works Compiles your skill first Shows all available tools Choose which tool to test Dynamic prompts based on tool's schema Runs tool in secure VM sandbox Shows output or error messages ### Example Session ```bash theme={null} $ lua test 🧪 Testing Lua skill... 📦 Compiling code first... ✅ Skill compiled successfully 📄 Loaded environment variables from .env file ? 🔧 Select a tool to test: ❯ get_weather - Get the weather for a given city create_product - Create a new product search_products - Search products get_user_data - Get user data ✅ Selected tool: get_weather 📝 Enter input values: ? city (required): London 🚀 Executing tool... Input: { "city": "London" } ✅ Tool execution successful! Output: { "weather": "3", "city": "London", "temperature": 15.2, "description": "Windspeed 12.3 km/h" } ``` ### Features * ✅ **Dynamic Prompts** - Based on Zod schema * ✅ **Type Validation** - Validates inputs automatically * ✅ **Environment Loading** - Loads `.env` and `lua.skill.yaml` variables * ✅ **Secure Sandbox** - Isolated VM execution * ✅ **Detailed Errors** - Clear error messages ### Testing Complex Inputs ```bash theme={null} # Nested objects ? shippingAddress.street (required): 123 Main St ? shippingAddress.city (required): New York ? shippingAddress.zip (required): 10001 # Arrays ? items[0].id (required): product_123 ? items[0].quantity (required): 2 ? Add another item? No # Optional fields ? description (optional): [Press Enter to skip] ``` ## lua push Push your compiled components to the Lua server. ```bash theme={null} lua push # Interactive selection lua push skill # Push a skill lua push persona # Push persona lua push webhook # Push a webhook lua push job # Push a job lua push preprocessor # Push a preprocessor lua push postprocessor # Push a postprocessor lua push mcp # Push an MCP server lua push all --force # Push all components ``` ### Options | Option | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--name ` | Entity name to push (non-interactive) | | `--set-version ` | Version to set (e.g., 1.0.5) | | `--force` | Skip all confirmation prompts | | `--auto-deploy` | Automatically deploy to production after push | | `--no-include-source` | Skip the per-skill source attach after push. Default is to attach, so the admin Builder UI source panel reflects your CLI edits. The attach is non-fatal — a failure logs a warning but doesn't fail the push. | | `--fresh` | (Only for `lua push backup`) Build the backup manifest by walking the project directory from disk, instead of reading the compiled `dist-v2/manifest.json`. Use this to capture out-of-band file changes (e.g. Builder writes). | **Auto-backup-push:** Every `lua push ` now synchronously runs a fresh-from-disk backup as the final step. If the backup fails, the command exits non-zero — no more silent partial success where the primitive landed but local source never reached the canonical store. ### Non-Interactive Mode ```bash theme={null} # Push specific skill with version lua push skill --name mySkill --set-version 1.0.5 # Push all components lua push all --force # Push and auto-deploy to production lua push all --force --auto-deploy # Push webhook with version lua push webhook --name payment-hook --set-version 2.0.0 # Push job with version lua push job --name daily-report --set-version 1.0.0 ``` **Push All:** Use `lua push all --force` to push all components at once. Add `--auto-deploy` to also activate/deploy them. ### Usage Modes **Default behavior - prompts for selection** ```bash theme={null} $ lua push ? What would you like to push? › skill persona ``` Best for: When you're not sure or want to see options **Skip prompt and push skill directly** ```bash theme={null} $ lua push skill # No prompt - goes straight to pushing skill ``` Best for: Quick iterations, automation, when you know what you want **Skip prompt and push persona directly** ```bash theme={null} $ lua push persona # No prompt - goes straight to pushing persona ``` Best for: Persona-only updates, faster workflow ### What It Does (Skills) Choose which skill to push (if multiple) Enter new version number (auto-suggests next patch) ``` Current version: 1.0.0 ? Enter new version to push: (1.0.1) ``` Updates version in `lua.skill.yaml` Validates your API key Automatically compiles the skill Uploads bundles to server Choose to deploy immediately or later ``` ? Would you like to deploy this version to production now? (y/N) ``` ### Example: Push Only (Interactive) ```bash theme={null} $ lua push ? What would you like to push? › skill persona 📦 Pushing skill: customer-service Current version: 1.0.0 ? Enter new version to push: (1.0.1) ⏎ 📝 Updating version from 1.0.0 to 1.0.1 ✅ Authenticated 🔄 Compiling skill... ✅ Skill compiled successfully - 10 tools bundled 🔄 Pushing version to server... ✅ Version 1.0.1 of "customer-service" pushed successfully ? Would you like to deploy this version to production now? No [Version pushed, use 'lua deploy' to deploy later] ``` ### Example: Push Only (Direct Mode) ```bash theme={null} $ lua push skill 📦 Pushing skill: customer-service Current version: 1.0.0 ? Enter new version to push: (1.0.1) ⏎ 📝 Updating version from 1.0.0 to 1.0.1 ✅ Authenticated 🔄 Compiling skill... ✅ Skill compiled successfully - 10 tools bundled 🔄 Pushing version to server... ✅ Version 1.0.1 of "customer-service" pushed successfully ? Would you like to deploy this version to production now? No [Version pushed, use 'lua deploy' to deploy later] ``` ### Example: Push and Deploy ```bash theme={null} $ lua push 📦 Pushing skill: order-management Current version: 0.5.0 ? Enter new version to push: 1.0.0 📝 Updating version from 0.5.0 to 1.0.0 ✅ Authenticated 🔄 Compiling skill... ✅ Skill compiled successfully - 8 tools bundled 🔄 Pushing version to server... ✅ Version 1.0.0 of "order-management" pushed successfully ? Would you like to deploy this version to production now? Yes ⚠️ WARNING: You are about to deploy to PRODUCTION! ⚠️ This will affect ALL users immediately. ? Are you absolutely sure you want to deploy? Yes 🔄 Publishing version... ✅ Version 1.0.0 deployed successfully to production ``` ### Version Management The command auto-suggests the next patch version: ``` Current: 1.0.0 → Suggests: 1.0.1 Current: 1.5.9 → Suggests: 1.5.10 Current: 2.0.0 → Suggests: 2.0.1 ``` Press Enter to accept or type your own version You can enter any valid semver version: ``` Current: 0.5.0 ? Enter: 1.0.0 # Major release Current: 1.2.3 ? Enter: 1.3.0 # Minor update Current: 2.0.0 ? Enter: 2.0.1-beta # Pre-release ``` Follow semver conventions: * **MAJOR** (2.0.0): Breaking changes * **MINOR** (1.1.0): New features * **PATCH** (1.0.1): Bug fixes ```yaml theme={null} # In lua.skill.yaml skills: - name: my-skill version: 1.0.1 # Auto-updated by push ``` **Version Conflict Avoidance**: When using `--force`, the CLI automatically checks the server for the highest existing version and suggests the next available version. This prevents "Version already exists" errors during automated deployments. ### Deploy Now or Later? **Push now, test, deploy when ready** ```bash theme={null} $ lua push ? Deploy now? No # Default # Test in sandbox $ lua chat → Sandbox mode # Deploy when satisfied $ lua deploy ``` **Best for:** * Major changes * Need more testing * Team coordination * Off-peak deployment **One-command deployment** ```bash theme={null} $ lua push ? Deploy now? Yes ? Absolutely sure? Yes ✅ Deployed ``` **Best for:** * Critical hotfixes * Small bug fixes * Well-tested changes * Urgent updates ### Important Notes **Version Management:** * Always increments version (cannot overwrite) * Version is updated in `lua.skill.yaml` * All versions are preserved on server * Can deploy any previous version **Immediate Deployment:** * Requires two confirmations for safety * Affects all users immediately * Have rollback plan ready * Monitor after deployment ### Version Management The version number is the **only field** in `lua.skill.yaml` you should manually edit: ```yaml theme={null} skills: - name: my-skill version: 1.0.1 # Increment this before pushing ``` Then push: ```bash theme={null} lua push ``` ### Requirements * Must be in skill directory with `lua.skill.yaml` * Must have valid API key (`lua auth configure`) * Version must not already exist on server ## lua deploy Deploy a specific version of a single primitive to production (all users). ```bash theme={null} lua deploy [type] ``` `type` is optional — when omitted, an interactive menu lets you pick. Valid types: `skill`, `webhook`, `job`, `preprocessor`, `postprocessor`, `persona`, `all`. `lua deploy` is the original, single-primitive release command and remains fully supported — but for agents already using [agent versions](/cli/version-command), `lua version create` + `lua version promote` is the recommended flow: it snapshots and switches *every* primitive at once, atomically, instead of one at a time. See [The release flow, end to end](/cli/version-command#the-release-flow-end-to-end) for how `push`, `deploy`, and `version` fit together. **What `lua deploy` actually does depends on your agent's history:** * If the agent has **promoted at least one agent version**, `lua deploy` performs a **scoped promote**: it automatically creates and promotes a new agent version identical to the current one, except for the primitive you just deployed. The change is live immediately, and it still shows up in `lua version list` (tagged with a `deploy …` message) so your version history has no gaps. * If the agent has **never created or promoted an agent version**, `lua deploy` activates the primitive directly, exactly as described below. ### Options | Option | Description | | --------------------- | ------------------------------------------------------- | | `--name ` | Entity name to deploy (non-interactive selection) | | `--set-version ` | Version to deploy, or `'latest'` for the newest version | | `--force` | Skip confirmation prompt | **Deprecated flags** — still accepted for backward compatibility but prefer the generic equivalents above: | Deprecated | Use instead | | ----------------------- | --------------------- | | `--skill-name ` | `--name ` | | `--skill-version ` | `--set-version ` | ### Usage ```bash theme={null} # Interactive — prompts for type, entity, and version lua deploy # Deploy a specific primitive type lua deploy skill lua deploy webhook lua deploy job lua deploy preprocessor lua deploy postprocessor lua deploy persona # Deploy latest version of every primitive at once lua deploy all --force ``` ### Non-Interactive Mode ```bash theme={null} # Deploy a specific skill version lua deploy skill --name mySkill --set-version 1.0.5 --force # Deploy latest webhook version lua deploy webhook --name myWebhook --set-version latest --force # Deploy latest of everything (CI/CD) lua deploy all --force ``` ### What It Does Choose the primitive type (or pass as argument to skip) Pick which skill / webhook / job / etc. to deploy (auto-selected if only one exists) Lists all pushed versions from the server Choose which version to deploy (or pass `--set-version latest`) Shows warning about production deployment (skip with `--force`) Publishes the selected version — immediately live for all users ### Example ```bash theme={null} $ lua deploy skill 📦 Deploying Skill: mySkill 🔄 Fetching available versions... ? Select a version to deploy: 1.0.2 - Created: Oct 3, 2025 by you@example.com 1.0.1 - Created: Oct 2, 2025 by you@example.com ❯ 1.0.0 (CURRENT) - Created: Oct 1, 2025 by you@example.com ? ⚠️ Warning: This version will be deployed to all users. Do you want to proceed? Yes 🔄 Publishing version... ✅ Skill "mySkill" v1.0.0 deployed successfully ``` ### Features * Works for all primitive types — not just skills * Shows all available versions with currently deployed one highlighted * Requires explicit confirmation (skip with `--force`) * Immediate deployment (no rollback delay) * `lua deploy all --force` deploys latest version of every primitive in one command * For agents with agent-version history, performs a scoped promote behind the scenes — see the [note above](#lua-deploy) **Deployment is immediate!** If `lua deploy` reports success, that exact code serves the very next invocation of the agent (allow up to roughly a minute for edge caches to catch up). All users will get the new version right away. Test thoroughly with `lua chat` first — sandbox testing never touches what's live. ## lua push backup Backup your project source files to cloud storage for disaster recovery and team collaboration. Push your project source files to cloud storage for safekeeping. Uses content-addressed storage with automatic deduplication for efficient backups. ```bash theme={null} lua push backup # Backup project sources lua push backup --force # Force backup even if up-to-date lua push backup --skip-compile # Skip compilation (assume already compiled) ``` ### Options | Option | Description | | ---------------- | ---------------------------------------------------- | | `--force` | Force backup even if hash matches previous backup | | `--skip-compile` | Skip compilation step (assumes dist/ already exists) | ### What Gets Backed Up The backup includes all source files tracked in your compilation manifest: * **Source Code**: `src/` directory (TypeScript files) * **Configuration**: `lua.skill.yaml`, `tsconfig.json`, `package.json` * **Environment Template**: `.env.example` (if exists) * **Documentation**: `README.md` (if exists) **NOT backed up**: `node_modules/`, `dist/`, `.env` (secrets), `.git/` (use Git for version control) ### How It Works Ensures manifest is fresh (runs `lua compile` if needed) Creates a content hash of all source files for deduplication Verifies if this exact project state already exists on server Uploads only new/changed files (content-addressed storage) * Compares file hashes with server * Uploads missing blobs in batches * Deduplicates identical files across backups Saves the project structure and file references to MongoDB Updates `lua.skill.yaml` with backup hash for drift detection ### Example Output ```bash theme={null} $ lua push backup ℹ️ Project hash: 8f3d4a2b1c9e... 🔄 Checking server for existing backup... 🔄 Checking which files need upload... ℹ️ Files: 42 total, 38 already stored, 4 to upload ℹ️ Upload size: 12.3 KB (base64) 🔄 Uploading 4 new files to storage... 🔄 Uploaded 4/4 files... 🔄 Saving backup manifest... ✅ Backup pushed successfully! Files: 42 New uploads: 4 Deduplicated: 38 Hash: 8f3d4a2b1c9e... ``` ### Restoring from Backup To restore a backed-up project on a new machine: ```bash theme={null} # Initialize with restore flag lua init --agent-id abc123 --restore-sources ``` This will: 1. Create a new project directory 2. Download all source files from cloud storage 3. Restore the exact project state from backup 4. Install dependencies **Interactive restore:** ```bash theme={null} $ lua init --agent-id abc123 ? Would you like to restore project sources from backup? Yes 🔄 Fetching backup manifest... ✅ Found backup with 42 files 🔄 Downloading source files... 🔄 Downloaded 42/42 files... ✅ Sources restored successfully! 💡 Run 'lua compile' to rebuild from restored sources ``` ### Use Cases **Recover from lost laptop or corrupted files:** ```bash theme={null} # On new machine lua init --agent-id abc123 --restore-sources npm install lua compile lua test ``` All your source code is back! **Share project with team members:** ```bash theme={null} # Team member A pushes backup lua push backup # Team member B clones it lua init --agent-id abc123 --restore-sources ``` No Git setup required for quick sharing. **Move project between machines:** ```bash theme={null} # Old machine lua push backup # New machine lua init --agent-id abc123 --restore-sources ``` Fresh install with all your code intact. **Automatic backups before deployments:** ```bash theme={null} # In CI pipeline lua push backup --force lua push all --force --auto-deploy ``` Always have a recovery point. ### Content-Addressed Storage Backups use **content-addressed storage** (like Git): * Each file is stored by its SHA-256 hash * Identical files are stored only once * Subsequent backups only upload changed files * Extremely efficient for large projects with small changes **Example:** If you change one line in one file, only that file is re-uploaded. The other 99 files reference existing blobs. ### Best Practices ```bash theme={null} lua push backup # Safety checkpoint # Make risky changes lua test lua push skill ``` If something breaks, restore from backup. **Backup ≠ Version Control** * **Backup**: Disaster recovery, single snapshot * **Git**: Full history, branching, collaboration Use both: ```bash theme={null} git commit -m "Add feature" # Version control lua push backup # Disaster recovery snapshot ``` ```bash theme={null} lua push backup --force lua push all --force --auto-deploy ``` Always have a recovery point before production changes. Add to your deployment pipeline: ```yaml theme={null} - name: Backup sources run: lua push backup --force - name: Deploy run: lua push all --force --auto-deploy ``` ## Complete Workflow ### New Project Workflow ```bash theme={null} # 1. Authenticate lua auth configure # 2. Initialize mkdir my-skill && cd my-skill lua init # 3. Test lua test # 4. Push lua push # 5. Deploy lua deploy ``` ### Development Workflow ```bash theme={null} # Configure environment if needed lua env sandbox # Add new API keys locally lua env production # Update production env vars # Make changes to src/tools/*.ts # Test your agent lua chat # Choose sandbox mode # Optional: Test specific tools lua test # For debugging individual tools # When satisfied, push (direct mode - faster!) lua push skill # Update production persona if needed lua persona production # Or: lua push persona # Deploy to production lua deploy ``` ### Quick Fix Workflow ```bash theme={null} # 1. Edit file vim src/tools/MyTool.ts # 2. Test lua test # 3. Push and deploy (direct mode) lua push skill lua deploy ``` These workflows use `lua push` + `lua deploy` — the fastest path for a single primitive, and exactly how a brand-new agent's first release works. Once an agent has releases it's tracking as [agent versions](/cli/version-command), prefer `lua version create` + `lua version promote` when you want several primitives to switch together atomically. `lua deploy` still works at that point too — it performs a scoped promote under the hood — see [The release flow, end to end](/cli/version-command#the-release-flow-end-to-end). ## Troubleshooting **Error**: `❌ No lua.skill.yaml found` **Solution**: Run command from skill directory or run `lua init` first **Error**: `❌ Version 1.0.0 already exists on the server` **Solution**: Increment version in `lua.skill.yaml`: ```yaml theme={null} skills: - version: 1.0.1 # Increment this ``` **Error**: `❌ No index.ts found` **Solution**: Create `src/index.ts` with skill definition: ```typescript theme={null} import { LuaSkill } from "lua-cli"; const skill = new LuaSkill({...}); ``` **Error**: `❌ Tool name invalid` **Solution**: Tool names can only contain: `a-z`, `A-Z`, `0-9`, `-`, `_` ```typescript theme={null} // ✅ Good name = "get_weather" // ❌ Bad name = "get weather" // No spaces ``` **Error**: `❌ Cannot find module 'lua-cli'` **Solution**: Install dependencies: ```bash npm theme={null} npm install ``` ```bash yarn theme={null} yarn install ``` ```bash pnpm theme={null} pnpm install ``` ## Best Practices Always test locally first: ```bash theme={null} lua test # Test individual tools lua chat # Test conversationally lua push # Then push ``` * **PATCH** (1.0.1): Bug fixes * **MINOR** (1.1.0): New features * **MAJOR** (2.0.0): Breaking changes ```bash theme={null} lua push # Upload version lua chat # Test in sandbox # If good, then: lua deploy # Deploy to production ``` Don't delete old versions - they serve as rollback points ## Next Steps Learn about live development with auto-reload Follow a complete tutorial Snapshot and promote every primitive together, atomically # Skills Command Source: https://docs.heylua.ai/cli/skills-command View and manage skills in sandbox and production environments ## Overview The `lua skills` command provides a dedicated interface for viewing local skills and managing production skill deployments. ```bash theme={null} lua skills # Interactive: choose environment lua skills sandbox # Direct: view local skills lua skills staging # Direct: alias for sandbox lua skills production # Direct: manage production skills ``` Dedicated skills management command with environment-specific features! ### Non-Interactive Mode ```bash theme={null} # View local skills (sandbox) lua skills sandbox view # View all production skills lua skills view # View skill versions lua skills versions --skill-name mySkill # Deploy specific version lua skills deploy --skill-name mySkill --skill-version 1.0.3 # Deploy latest version lua skills deploy --skill-name mySkill --skill-version latest ``` `lua skills sandbox view` prints local skills and exits (no interactive menu) - perfect for CI/CD pipelines and automation scripts. | Option | Description | | ----------------------- | -------------------------------- | | `--skill-name ` | Skill name (for versions/deploy) | | `--skill-version ` | Version or 'latest' (for deploy) | | Environment | Action | Description | Required Options | | ------------ | ---------- | --------------------------- | --------------------------------- | | `sandbox` | `view` | Print local skills and exit | None | | `production` | `view` | List all production skills | None | | `production` | `versions` | View skill version history | `--skill-name` | | `production` | `deploy` | Deploy specific version | `--skill-name`, `--skill-version` | View local skills from lua.skill.yaml Manage deployed skills and versions View all skill versions Deploy specific versions to production ## Usage Modes **Default behavior - prompts for environment** ```bash theme={null} $ lua skills ? Select environment: › 🔧 Sandbox (local skills) 🚀 Production (deployed skills) ``` Best for: When you're exploring or not sure which environment **Skip prompt and view local skills** ```bash theme={null} $ lua skills sandbox # No prompt - shows local skills immediately ``` Best for: Quick reference during development **Skip prompt and manage production skills** ```bash theme={null} $ lua skills production # No prompt - opens production management immediately ``` Best for: Fast deployments, production management ## Sandbox Mode View skills defined in your local `lua.skill.yaml` file. ```bash theme={null} lua skills sandbox ``` ### Features Display all skills from your configuration ``` ============================================================ 🔧 Local Skills (Sandbox) ============================================================ 1. customer-service Version: 1.0.5 Skill ID: skill_abc123 2. order-management Version: 0.8.2 Skill ID: skill_xyz789 ``` Access to common commands: * 📦 Compile skill * ☁️ Push to server * 🔄 Refresh list ### Example Session ```bash theme={null} $ lua skills sandbox ============================================================ 🔧 Local Skills (Sandbox) ============================================================ 1. customer-service Version: 1.0.5 Skill ID: skill_abc123 ? What would you like to do? › Refresh list Compile skill Push to server Exit # Select "Compile skill" 🔨 Compiling Lua skill... ✅ Skill compiled successfully - 10 tools bundled ``` ### Use Cases Check skill names and IDs without opening the config file: ```bash theme={null} lua skills sandbox ``` Verify your skills are correctly defined: ```bash theme={null} lua skills sandbox # Check versions and IDs lua compile ``` View all skills in a multi-skill project: ```bash theme={null} lua skills sandbox # Shows all skills defined in lua.skill.yaml ``` ## Production Mode View deployed skills and manage production versions. ```bash theme={null} lua skills production ``` ### Features List all skills with active versions ``` ============================================================ 🚀 Production Skills ============================================================ 1. customer-service Active Version: 1.0.5 Skill ID: skill_abc123 2. order-management Active Version: 0.8.2 Skill ID: skill_xyz789 ``` View complete version history for each skill ``` ? Select skill: customer-service Version History for customer-service: • 1.0.5 (ACTIVE) - Created: Oct 12, 2025 • 1.0.4 - Created: Oct 10, 2025 • 1.0.3 - Created: Oct 5, 2025 • 1.0.2 - Created: Oct 1, 2025 ``` Deploy any version to production ``` ? Select version to deploy: 1.0.4 ⚠️ WARNING: You are about to deploy to PRODUCTION! ⚠️ This will affect ALL users immediately. ? Are you absolutely sure? Yes 🔄 Deploying version 1.0.4... ✅ Version 1.0.4 deployed successfully ``` ### Example Session ```bash theme={null} $ lua skills production ✅ Authenticated ============================================================ 🚀 Production Skills ============================================================ 1. customer-service Active Version: 1.0.5 Skill ID: skill_abc123 2. order-management Active Version: 0.8.2 Skill ID: skill_xyz789 ? What would you like to do? › View version history Deploy specific version Refresh list Exit # Select "View version history" ? Select skill: customer-service Version History for customer-service: • 1.0.5 (ACTIVE) - Created: Oct 12, 2025 10:30 AM • 1.0.4 - Created: Oct 10, 2025 3:45 PM • 1.0.3 - Created: Oct 5, 2025 9:15 AM ? Select version to deploy: 1.0.4 ⚠️ WARNING: You are about to deploy to PRODUCTION! ⚠️ This will affect ALL users immediately. Current: 1.0.5 New: 1.0.4 ? Are you absolutely sure you want to deploy? Yes 🔄 Deploying version 1.0.4... ✅ Version 1.0.4 deployed successfully to production ``` ### Use Cases Verify what's running in production: ```bash theme={null} lua skills production # See active versions at a glance ``` Roll back to a previous version: ```bash theme={null} lua skills production # Select skill → View history → Deploy older version ``` Deploy a specific version instead of latest: ```bash theme={null} lua skills production # Choose exact version to deploy ``` Review deployment history: ```bash theme={null} lua skills production # View when each version was created ``` ### Safety Features **Production deployments require two confirmations:** 1. Select version to deploy 2. Confirm deployment (defaults to "No") This prevents accidental deployments and ensures you're deploying the right version. ## Complete Workflows ### Development Workflow ```bash theme={null} # 1. Check local skills lua skills sandbox # 2. Make changes to tools vim src/tools/MyTool.ts # 3. Compile lua compile # 4. Test lua test # 5. Push new version lua push skill # 6. Check production before deploying lua skills production # 7. Deploy lua deploy ``` ### Rollback Workflow ```bash theme={null} # 1. Check what's deployed lua skills production # 2. View version history # Select skill → View history # 3. Deploy previous version # Select older version → Confirm # Done! Previous version is live ``` ### Multi-Skill Management ```bash theme={null} # View all skills lua skills sandbox # Work on specific skill cd my-project vim src/tools/CustomerServiceTool.ts # Compile specific skill lua compile # Push only that skill lua push skill # Deploy to production lua skills production # Select the skill you just pushed ``` ## Comparison with Other Commands | Command | Purpose | Environment | | ----------------------- | --------------------------------- | ----------- | | `lua skills sandbox` | View local skill configuration | Local | | `lua skills production` | Manage deployed skills | Server | | `lua compile` | Compile skills to bundles | Local | | `lua push skill` | Upload new skill version | Server | | `lua deploy` | Deploy to production (all skills) | Server | | `lua production` | View production environment | Server | ## Best Practices Always check what's currently deployed: ```bash theme={null} lua skills production # Review active versions lua deploy ``` Don't delete old versions - they're your rollback points: ```bash theme={null} lua skills production # View history → Shows all available versions ``` Always test locally before pushing: ```bash theme={null} lua skills sandbox # Verify configuration lua test # Test tools lua chat # Test conversationally lua push skill # Then push ``` Use semantic versioning and keep notes: ```yaml theme={null} # In lua.skill.yaml skills: - name: customer-service version: 1.1.0 # Minor: Added FAQ tool ``` ## Troubleshooting **Error**: "No skills found in lua.skill.yaml" **Solution:** 1. Ensure you're in a skill directory 2. Check `lua.skill.yaml` has skills defined 3. Run `lua compile` to populate skills **Error**: "Failed to fetch production skills" **Solution:** 1. Verify authentication: `lua auth key` 2. Check network connection 3. Ensure you've pushed at least one version **Problem**: Pushed version doesn't appear **Solution:** 1. Refresh the list 2. Verify push was successful 3. Check you're viewing correct skill **Error**: "Version not found" **Solution:** 1. Verify version exists: `lua skills production` 2. Check version number matches exactly 3. Ensure version was pushed successfully ## Related Commands Compile skills before viewing in sandbox Upload new skill versions Deploy active skill versions View overall production environment ## Next Steps Learn about compiling and pushing skills Comprehensive production management Complete skill development tutorial Managing multiple skills # Source Command Source: https://docs.heylua.ai/cli/source-command Inspect and roll back your agent's workspace source version history ## Overview `lua source` manages your agent's workspace backup version history. Every `lua push` records the canonical state of your project. `lua source list` shows the version timeline. For rollback, prefer [`lua version promote`](/cli/version-command) — instant and atomic, no re-upload; `lua source rollback` (deprecated) remains available for restoring past source files into your workspace. ```bash theme={null} lua source list # Recent 50 versions, active marked with * lua source list --all # Full history lua source rollback --version 5 # Restore v5 (with confirmation) lua source rollback --version 5 --force # Restore v5 (no prompt) ``` ## Subcommands ### `lua source list` Prints a table of source versions for the current agent, with the active version starred. | Option | Description | | ------------- | ------------------------------------------------ | | `--all` | Show all versions instead of the most recent 50. | | `--limit ` | Cap output at `n` versions. Default: `50`. | ```bash theme={null} lua source list # most recent 50 lua source list --all # full history lua source list --limit 10 # cap at 10 ``` ### `lua source rollback` **Deprecated for rollback.** With agent versioning, use [`lua version promote `](/cli/version-command) instead — it swaps the live agent state instantly without re-uploading files. `lua source rollback` still works for restoring past *source files* into your workspace. Downloads a past version's files into your local workspace, then auto-pushes the rolled-back state as the next version. | Option | Description | | --------------- | --------------------------------------------- | | `--version ` | **Required.** Version number to roll back to. | | `--force` | Skip the confirmation prompt. | ```bash theme={null} lua source rollback --version 5 lua source rollback --version 5 --force ``` ## How Rollback Works History is **append-only**. Rolling back to v5 does **not** overwrite v5 — instead: 1. The CLI downloads v5's files into your local workspace. 2. It then auto-pushes the rolled-back state, creating v(latest+1) with the same contents as v5. You end up with an explicit new version at the head that represents "we returned to v5 on this date." The original v5 is preserved verbatim. This makes rollbacks safe — you can always roll back from your rollback. ``` Before: v1, v2, v3, v4, v5 (active) ↓ lua source rollback --version 2 After: v1, v2, v3, v4, v5, v6 (active, contents = v2) ``` ## When to Use * **Recover from a bad push.** Prefer `lua version promote ` (instant). Use `lua source rollback --version ` only when you need the actual source files back in your workspace. * **Compare past versions.** `lua source list --all` shows the timeline; pair with the [admin dashboard](/cli/utility-commands#admin) to diff. * **Cross-machine recovery.** Lost your local workspace? `lua init --agent-id --restore-sources` pulls the active version. Use `lua source rollback` to retrieve any other version. Rollback **overwrites your local workspace** with the chosen version's contents. If you have local edits you haven't pushed, push them first (or back them up) before rolling back. ## Common Workflow ```bash theme={null} # Something is wrong in production — what changed recently? lua source list # Roll back to a known-good version lua source rollback --version 12 # After investigation, re-apply your fix and push forward # (the rollback created v13; your fix will push as v14) lua push all --force ``` ## Related * [Push Command](/cli/skill-management#lua-push) — every `push` creates a new version * [Sync Command](/cli/sync-command) — `lua sync --pull` restores the active version * [Init Command](/cli/skill-management#lua-init) — `--restore-sources` pulls the active version on first init # Sync Command Source: https://docs.heylua.ai/cli/sync-command Detect and resolve drift between server state and local code ## Overview The `lua sync` command detects differences (drift) between your server-deployed configuration and local code, helping you keep them in sync. ```bash theme={null} lua sync ``` The sync command helps prevent accidental overwrites when someone updates the agent from the admin dashboard while you're working locally. ## What Gets Synced | Component | Description | | ----------- | ------------------------------------------------------- | | **Name** | Agent name defined in `LuaAgent({ name: "..." })` | | **Persona** | Agent persona defined in `LuaAgent({ persona: "..." })` | ## How It Works 1. **Compiles code** - Ensures manifest is fresh (runs `lua compile` internally) 2. **Fetches server state** - Gets the latest published persona and agent name from the server 3. **Compares with local** - Checks your compiled manifest against server state 4. **Shows colored diff** - Displays exactly what changed 5. **Prompts for action** - Update local from server or keep local Sync now compiles before checking drift to ensure accurate comparison with the latest local changes. ## Usage ### Interactive Sync ```bash theme={null} lua sync ``` If drift is detected, you'll see a colored diff: ``` ============================================================ PERSONA DIFF - Server (deployed) + Local (code) ============================================================ - Old Agent Name + New Agent Name ... (unchanged lines) ... ============================================================ ? What would you like to do with persona? ❯ 📤 Push local to server 📥 Pull server to local ⏭️ Skip ``` **Diff colors:** * 🔴 **Red** (`-`) = Line exists on server but not in local code * 🟢 **Green** (`+`) = Line exists in local code but not on server * Gray = Unchanged lines (abbreviated if many) ### During Compile Sync is **opt-in** during `lua compile`. By default, drift detection is **disabled** for faster compilation: ```bash theme={null} lua compile # Default: no drift check (fast) lua compile --sync # Enable drift detection (checks before compiling) ``` This opt-in behavior is ideal for faster development iterations. Use `--sync` when you need to ensure alignment with the server. ## Options ### Sync Command Options | Flag | Description | | ---------- | --------------------------------------------------------------------------------------------------------------- | | `--check` | Check for drift only, exit code 1 if drift found (CI validation) | | `--pull` | Pull server state to local without prompting. Source files are restored from the snapshot. | | `--accept` | Deprecated alias for `--pull` — kept for backwards compatibility. New scripts should use `--pull`. | | `--push` | Push local changes to server without prompting | | `--force` | Skip the local-changes conflict check (use with `--pull` / `--accept`). Destructive — overwrites local changes. | ### Compile Integration | Flag | Description | | ----------- | --------------------------------------------------------------- | | `--sync` | Enable drift detection during compile (checks before compiling) | | `--verbose` | Show detailed compilation output | ### Non-Interactive Mode ```bash theme={null} # Check for drift only (returns exit code 1 if drift detected) lua sync --check # Pull server state to local without prompting lua sync --pull # Pull and bypass the local-changes conflict guard (destructive) lua sync --pull --force # Auto-push local changes to server without prompting lua sync --push ``` ### Examples ```bash theme={null} # Check for drift interactively lua sync # Check only, fail if drift (for CI) lua sync --check # Pull server state to local lua sync --pull # Auto-push local changes to server lua sync --push # Compile with sync check (opt-in) lua compile --sync ``` ### CI/CD Workflow Example ```yaml theme={null} # GitHub Actions example name: Deploy Agent on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check for drift run: lua sync --check - name: Compile run: lua compile - name: Push version run: lua push skill --name mySkill --set-version ${{ github.sha }} --force - name: Deploy to production run: lua deploy --skill-name mySkill --skill-version latest --force ``` ## Common Scenarios ### Scenario 1: Someone Updated Persona in Dashboard You're developing locally, and a colleague updated the persona from the admin dashboard. ```bash theme={null} $ lua sync ⚠️ Drift detected! ============================================================ PERSONA DIFF - Server (deployed) + Local (code) ============================================================ - Updated persona from dashboard + Your local persona version ? What would you like to do with persona? 📤 Push local to server # Overwrite server with your version ❯ 📥 Pull server to local # Get their changes ⏭️ Skip # No changes ``` ### Scenario 2: Forgot to Push Changes You made changes locally but forgot to push before reverting your code. ```bash theme={null} $ lua sync ✅ No drift detected. Local code is in sync with server. ``` ### Scenario 3: CI/CD Pipeline In your deployment pipeline, you might want to ensure no drift or push local as source of truth: ```bash theme={null} # Compile only (default - no drift check) lua compile # Compile with drift check lua compile --sync # Or push local changes to server before compile lua sync --push && lua compile ``` ## Best Practices Always run `lua sync` when starting a new session to catch any changes made by teammates or from the dashboard. ```bash theme={null} cd my-agent lua sync # Check for drift lua compile # Start development ``` When you want to ensure you have the latest server state: ```bash theme={null} lua sync --accept ``` This automatically updates your local code from the server without prompting. In automated pipelines, compile runs without drift check by default (fast): ```bash theme={null} lua compile && lua push skill ``` Add `--sync` only if you need to validate against server state first. After syncing from server, commit the changes to preserve them: ```bash theme={null} lua sync # Select "Update local from server" git add src/index.ts git commit -m "sync: update persona from server" ``` ## How Server Versions Work The sync command compares against the **latest published** persona version, not the currently active one: | Version Type | Description | Used for Sync? | | ------------- | ------------------------------- | -------------- | | **Draft** | Created but not deployed | ❌ No | | **Published** | Has been deployed at least once | ✅ Yes (latest) | | **Current** | Currently active in production | ❌ No | This means if you roll back to an older version in production, sync will still compare against the most recent push (not the rollback). ## Error Handling ``` ✅ No drift detected. Local code is in sync with server. ``` Your local code matches the server. No action needed. If the server is unreachable, sync will silently continue to avoid blocking your workflow. If no persona has been pushed to the server yet, sync will report no drift. ## Related Commands Compile with optional sync check Push persona to server Manage persona in sandbox/production # Triggers Command Source: https://docs.heylua.ai/cli/triggers-command Manage agent triggers — paste-anywhere URLs and SDK triggers that wake your agent on external events ## Overview `lua triggers` manages **agent triggers**: URLs that start an agent turn when an external service POSTs to them. There are two flavours, managed with the same command: * **URL triggers** — created entirely from the CLI with `lua triggers create`, no code required. Paste the printed URL into any service that can send a webhook; every delivery becomes an agent turn carrying the payload (optionally prefixed with an instruction you set at creation time). * **SDK triggers** — defined in code with [`defineTrigger`](/api/luatrigger) and deployed via `lua push`. These add declarative `verify` / `filter` / `transform` shaping in front of the agent turn. ```bash theme={null} lua triggers # Interactive management lua triggers list # List all triggers lua triggers create --name order-created # Create a URL trigger (prints the pasteable URL) lua triggers logs --trigger order-created # View execution history ``` For defining SDK triggers in code, see the [LuaTrigger API](/api/luatrigger). Looking for **integration triggers** (Linear, HubSpot, and other connected apps)? Those are managed with [`lua integrations webhooks`](/cli/integrations-command). ## Subcommands | Action | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `list` | List all triggers with type (URL or SDK), status, URL, and creation date. | | `create` | Create a URL trigger and print its pasteable URL. | | `logs` | Show execution history for a trigger, newest first. | | `activate` | Enable a trigger. | | `deactivate` | Disable a trigger — its URL stops firing, but is retained. | | `rotate-token` | Invalidate the current URL and mint a new one. | | `delete` | Remove a trigger. Its URL stops working immediately. A trigger with deployed SDK versions is **deactivated instead of deleted** (its URL stops firing; versions are retained). | Running `lua triggers` with no action opens an interactive menu covering all of the above. ## Options | Option | Description | | ---------------------- | ----------------------------------------------------------------------------------------- | | `--name ` | Trigger name (required for `create`). | | `--description ` | Trigger description (optional, for `create`). | | `--instruction ` | Instruction sent to the agent each time this trigger fires (optional, for `create`). | | `--trigger ` | Trigger name or ID (required for `logs`/`activate`/`deactivate`/`rotate-token`/`delete`). | | `--limit ` | Max executions to show for `logs` (default: 20, max: 200). | | `--json` | Output as JSON (for `list` and `logs`). | | `--force` | Skip confirmation prompts (for `delete`). | ## The Paste-Anywhere URL Workflow Create a trigger, paste its URL somewhere, and every POST to it becomes an agent turn: ```bash theme={null} lua triggers create --name daily-time --instruction "Reply with the current date and time" ``` ``` ✅ Trigger "daily-time" created 📝 Instruction sent to the agent on each fire: Reply with the current date and time ============================================================ 🔗 Trigger URL (paste it anywhere): https://trigger.heylua.ai/trigger/6978e0294d9c2007ed5cb129/9f2e6c1a-2b7d-4c03-9e88-1a2b3c4d5e6f ============================================================ Trigger is live. Fire it, then inspect executions: Fire: curl -X POST https://trigger.heylua.ai/trigger/6978e0294d9c2007ed5cb129/9f2e6c1a-2b7d-4c03-9e88-1a2b3c4d5e6f -H 'Content-Type: application/json' -d '{"hello":"world"}' Inspect: lua triggers logs --trigger daily-time 💡 If this URL leaks, run 'lua triggers rotate-token --trigger daily-time' to invalidate it and mint a new one. ``` The URL works from anywhere that can send an HTTP POST — CI pipelines, monitoring tools, Zapier, `curl`, another agent. The agent receives the request body as its message, prefixed with `[Trigger: ]` and the instruction if you set one. **The URL is the credential.** The token embedded in the URL is the only thing gating a plain URL trigger. Treat trigger URLs like API keys, and rotate them if they leak. For real authentication (HMAC signatures), define an SDK trigger with a [`verify` slot](/api/luatrigger#slots-at-least-one-required). ## Examples ```bash theme={null} # Interactive lua triggers # List everything (URL and SDK triggers) lua triggers list lua triggers list --json # Create URL triggers lua triggers create --name order-created lua triggers create --name order-created --description "Fires on new orders" lua triggers create --name daily-time --instruction "Reply with the current date and time" # Execution history lua triggers logs --trigger order-created lua triggers logs --trigger order-created --limit 5 --json # Pause and resume lua triggers deactivate --trigger order-created lua triggers activate --trigger order-created # Invalidate a leaked URL lua triggers rotate-token --trigger order-created # Delete lua triggers delete --trigger order-created --force ``` ## Output Shapes ### list Each trigger is shown with its type — **URL** (a plain paste-anywhere trigger) or **SDK** (a deployed `defineTrigger` version owns the pipeline) — plus its status, URL, and creation date. `--json` emits the same fields as machine-readable JSON. ### logs Executions are listed newest first, with a status per delivery: | Status | Meaning | | ---------------------------------- | --------------------------------------------------------------------------------------------------- | | ✅ `COMPLETED` | The agent turn finished; the agent's response text is shown with the entry. | | 🔄 `ACCEPTED` | Slots passed; the agent turn is still in flight. | | ⏳ `TIMED OUT` | Accepted long ago with no completion recorded. | | ❌ `FAILED` | A slot threw, the transform returned nothing, or the agent invocation failed — the error is shown. | | ⛔ `REJECTED (verify failed → 401)` | The SDK trigger's `verify` slot returned false. | | 🔇 `SKIPPED (filtered out)` | The SDK trigger's `filter` slot returned false — delivery acknowledged with 200, agent not invoked. | | 🚫 `SKIPPED (trigger inactive)` | The trigger was deactivated at delivery time. | ### rotate-token Prints the **new** URL and confirms the old one is dead: ``` ✅ Token rotated for "order-created" — the old URL no longer works 🔗 New trigger URL (update it everywhere it was pasted): https://trigger.heylua.ai/trigger/6978e0294d9c2007ed5cb129/7c1a40d8-5e92-4f6b-a3d1-8b9c0d1e2f3a ``` ## SDK Triggers and `lua push` SDK triggers are not created with this command — they are defined in code and deployed with `lua push`, which compiles each trigger, uploads a new version, and records it in `lua.skill.yaml`: ```yaml theme={null} triggers: - name: github-pr-assigned triggerId: 5f4c9a1e-2b7d-4c03-9e88-1a2b3c4d5e6f version: 1.0.1 ``` Once pushed, SDK triggers appear in `lua triggers list` alongside URL triggers, and `logs`, `activate`/`deactivate`, and `rotate-token` all work the same way. ## Common Workflow ```bash theme={null} # Edit your trigger in src/triggers/pr-assigned.trigger.ts, then: lua push # Build + deploy lua triggers list # Confirm it's live, copy the URL # Paste the URL into the external service (GitHub, Stripe, ...) curl -X POST -H 'Content-Type: application/json' -d '{"test":true}' lua triggers logs --trigger github-pr-assigned # Verify the delivery pipeline ``` ## Related * [LuaTrigger API](/api/luatrigger) — defining SDK triggers with verify/filter/transform * [LuaWebhook API](/api/luawebhook) — when you need full request/response control * [Webhooks Command](/cli/webhooks-command) — managing webhook primitives * [Integrations Command](/cli/integrations-command) — integration triggers for connected apps (Linear, HubSpot, ...) * [Env Command](/cli/env-command) — secrets for verify slots # Troubleshooting Source: https://docs.heylua.ai/cli/troubleshooting Common issues and solutions for Lua CLI ## Common Errors ### Authentication Errors **Error:** ``` ❌ No Lua CLI authentication found. Run `lua auth configure` or set `LUA_API_KEY`. ``` **Cause**: You haven't set up authentication yet. **Solution**: ```bash theme={null} lua auth configure ``` Choose **Email** to sign in with a renewable session, or choose **API Key** to save an existing scoped or legacy key unchanged. **Error:** ``` ❌ API key validation failed ``` **Causes**: * Key was revoked * Key was copied incorrectly * Extra spaces in key **Solutions**: 1. Verify you copied the complete key 2. Remove any extra spaces 3. Open the Lua dashboard, then create a replacement under **Settings → API Keys**: ```bash theme={null} lua admin ``` **Problem**: Email OTP code not received **Solutions**: 1. Check spam/junk folder 2. Wait 5 minutes (can be delayed) 3. Try again with `lua auth configure` 4. Use API Key method if available ### Project Initialization Errors **Error:** ``` ❌ No lua.skill.yaml found. Please run this command from a skill directory. ``` **Cause**: You're not in a skill project directory. **Solution**: 1. Navigate to your skill directory: ```bash theme={null} cd my-skill ``` 2. Or initialize a new project: ```bash theme={null} lua init ``` **Error:** ``` ❌ Directory is not empty. Please use an empty directory. ``` **Cause**: `lua init` requires an empty directory. **Solution**: ```bash theme={null} mkdir my-new-skill cd my-new-skill lua init ``` **Error:** ``` EACCES: permission denied ``` **Solutions**: * Check directory permissions * Don't use sudo (creates permission issues later) * Use a directory you own: ```bash theme={null} cd ~/projects mkdir my-skill cd my-skill lua init ``` ### Compilation Errors **Error:** ``` ❌ No index.ts found in current directory or src/ directory ``` **Cause**: Missing main skill file. **Solution**: Create `src/index.ts`: ```typescript theme={null} import { LuaSkill } from "lua-cli"; const skill = new LuaSkill({ name: "my-skill", description: "My skill description", context: "How to use this skill", tools: [] }); ``` **Error:** ``` ❌ Compilation failed: src/tools/MyTool.ts:10:5 - error TS2322: Type 'string' is not assignable to type 'number'. ``` **Cause**: TypeScript type error in your code. **Solution**: Fix the specific error mentioned: ```typescript theme={null} // Error at line 10 const age: number = "25"; // ❌ Wrong type const age: number = 25; // ✅ Correct ``` **Error:** ``` ❌ Cannot find module 'lua-cli' ``` **Cause**: Dependencies not installed. **Solution**: ```bash npm theme={null} npm install ``` ```bash yarn theme={null} yarn install ``` ```bash pnpm theme={null} pnpm install ``` **Error:** ``` ❌ Tool names can only contain alphanumeric characters, hyphens (-), and underscores (_). ``` **Cause**: Tool name contains invalid characters. **Solution**: Fix tool name: ```typescript theme={null} // ❌ Invalid name = "get weather"; // Spaces name = "get.weather"; // Dots name = "get@weather"; // Special chars // ✅ Valid name = "get_weather"; name = "get-weather"; name = "getWeather"; ``` ### Version Management Errors **Error:** ``` ❌ Version 1.0.0 already exists on the server ``` **Cause**: You've already pushed this version number. **Solution**: Increment the version number in `lua.skill.yaml` (this is the only field you should manually edit): ```yaml theme={null} skills: - name: my-skill version: 1.0.1 # Increment this ``` Then push again: ```bash theme={null} lua push ``` **Error:** ``` ❌ Version mismatch: config has 1.0.1, deploy.json has 1.0.0 ``` **Cause**: Changed version but didn't recompile. **Solution**: Recompile after version change: ```bash theme={null} lua compile lua push ``` ### Sync Errors **Message:** ``` ⚠️ Drift detected in: name, persona ``` **Cause**: Server has different agent configuration than your local code. This happens when someone updates the agent from the admin dashboard. **Solutions**: 1. **Run sync**: Use `lua sync` to review and resolve drift interactively 2. **Auto-accept**: Use `lua sync --accept` to automatically update local from server 3. **Auto-push**: Use `lua sync --push` to push local changes to server **Note**: By default, `lua compile` does NOT check for drift. Use `lua compile --sync` to enable drift detection during compilation. **To see the diff**: ```bash theme={null} lua sync ``` **Message:** ``` 💻 Local (code): (no persona in code) ``` **Cause**: The `LuaAgent` configuration is missing or has an empty name. **Solutions**: 1. Ensure your `src/index.ts` has a valid LuaAgent: ```typescript theme={null} const agent = new LuaAgent({ name: "my-agent", // Must not be empty! persona: "...", // ... }); ``` 2. Check for TypeScript compilation errors **Problem**: Sync silently fails or shows no drift when there should be. **Cause**: Network issues when fetching server state. **Solutions**: 1. Check internet connection 2. Verify API key is valid: `lua auth key` 3. Try again: `lua sync` ### Dev Mode Errors **Error:** ``` ❌ EADDRINUSE: address already in use :::3000 ``` **Cause**: Port 3000 is being used by another process. **Solutions**: 1. Stop the other process 2. Or kill process on port 3000: ```bash theme={null} # macOS/Linux lsof -ti:3000 | xargs kill -9 # Windows netstat -ano | findstr :3000 taskkill /PID /F ``` **Error:** ``` 💡 The skill doesn't exist on the server. Please run "lua push" first to deploy your skill. ``` **Cause**: Skill hasn't been pushed to server yet. **Solution**: ```bash theme={null} lua push # Push first lua chat # Then test in sandbox mode ``` **Problem**: Local changes not showing in sandbox mode. **Solutions**: 1. Verify file is in `src/` directory 2. Check file is actually saved 3. Ensure you selected "Sandbox" mode in `lua chat` 4. Check for compilation errors 5. Try again: ```bash theme={null} lua chat # Will recompile and push to sandbox ``` ### Deployment Errors **Error:** ``` ❌ No versions available to deploy ``` **Cause**: You haven't pushed any versions yet. **Solution**: ```bash theme={null} lua push # Push a version first lua deploy ``` **Error:** ``` ❌ Deployment failed: [error message] ``` **Solutions**: 1. Check API key is valid 2. Verify you have deploy permissions 3. Check network connection 4. Try again - might be temporary server issue ## Debugging Skills at Runtime When your tool returns unexpected data or you're unsure why a skill is behaving incorrectly, use this workflow. **Symptoms:** * Field is `undefined` when you expected a value * `results.data` is undefined * `results.count` is undefined * Properties like `entry.title` return undefined **The fix — log before you transform:** ```typescript theme={null} async execute(input: any) { const results = await Data.search('articles', input.query); // Add this FIRST, before any mapping console.log('Raw result:', JSON.stringify(results, null, 2)); // Then push and check logs: // lua logs --type skill --limit 5 } ``` **Common causes:** * Using `results.data` after `Data.search` — search returns a flat array, not `{ data: [...] }` * Using `results.count` after `Data.search` — use `results.length` * Using `entry.title` after `Data.get` — get entries are not proxied, use `entry.data.title` * Using `results.data` after `Products.search` — use `results.products` or iterate directly See the [Return Shape Reference](/api/data#return-shape-reference) and [Debugging guide](/cli/debugging). 1. Add `console.log('result:', JSON.stringify(result, null, 2))` to the suspicious spot 2. Run `lua push` (or use `lua chat` sandbox mode without pushing) 3. Send ONE test message: `lua chat -m "your test query"` 4. Run `lua logs --type skill --limit 10` — find your log entry 5. Read the actual shape, fix code once, verify **Don't deploy 5 times trying to patch blind.** Log first, deploy once. [Full debugging guide →](/cli/debugging) ```bash theme={null} # All skill logs (most recent) lua logs --type skill --limit 20 # Filter to a specific skill lua logs --type skill --name my-skill-name --limit 10 # JSON output (for parsing) lua logs --type skill --json # Other component types lua logs --type job --name daily-report --limit 5 lua logs --type webhook --name stripe --limit 10 lua logs --type preprocessor --limit 20 ``` Your `console.log()` output appears in the log message body under `🔍 DEBUG` entries. ## Platform-Specific Issues ### macOS **Cause**: CLI not in PATH **Solutions**: 1. Reinstall globally: ```bash theme={null} npm install -g lua-cli ``` 2. Or use npx: ```bash theme={null} npx lua-cli [command] ``` ### Windows **Error**: Script execution disabled **Solution**: Enable script execution: ```powershell theme={null} Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser ``` **Error**: `ENAMETOOLONG` **Solution**: Use shorter directory paths: ```bash theme={null} # Instead of C:\Users\YourName\Documents\Projects\MyCompany\Skills\my-skill # Use C:\projects\my-skill ``` ### Linux **Error**: Permission denied on global install **Solution**: Configure npm to install globally without sudo: ```bash theme={null} mkdir ~/.npm-global npm config set prefix '~/.npm-global' echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc npm install -g lua-cli ``` ## Node.js Issues **Error**: Requires Node.js >= 16.0.0 **Solution**: Update Node.js: 1. Visit [https://nodejs.org](https://nodejs.org) 2. Download LTS version 3. Or use nvm: ```bash theme={null} nvm install --lts nvm use --lts ``` **Warning**: Package deprecated warnings **Solution**: Usually safe to ignore deprecated warnings from dependencies. Update if issues occur: ```bash theme={null} npm update ``` **Error**: Conflicting peer dependencies **Solution**: Clear cache and reinstall: ```bash theme={null} rm -rf node_modules package-lock.json npm install ``` ## Network Issues **Error**: `ETIMEDOUT` or `ECONNREFUSED` **Solutions**: 1. Check internet connection 2. Check if behind firewall/proxy 3. Try again - might be temporary 4. Check server status **Error**: `UNABLE_TO_VERIFY_LEAF_SIGNATURE` **Solutions**: 1. Check system date/time is correct 2. Update CA certificates 3. If behind corporate proxy, may need proxy config **Problem**: Behind corporate proxy **Solution**: Configure npm proxy: ```bash theme={null} npm config set proxy http://proxy.company.com:8080 npm config set https-proxy http://proxy.company.com:8080 ``` ## Environment Variable Issues **Problem**: `env('MY_VAR')` returns undefined **Solutions**: 1. Check spelling in `.env` file: ```bash theme={null} # .env MY_VAR=value # Correct spelling ``` 2. Ensure `.env` is in project root 3. Restart command (variables loaded at startup) 4. Check `lua.skill.yaml`: ```yaml theme={null} skill: env: MY_VAR: value ``` **Problem**: Updated `.env` but changes not visible **Solution**: Restart the CLI command: ```bash theme={null} # Stop current process (Ctrl+C) lua chat # Start again ``` Or manage variables with: ```bash theme={null} lua env ``` ## Getting Help ### Diagnostic Information When reporting issues, include: ```bash theme={null} # CLI version lua --version # Node version node --version # npm version npm --version # Operating system uname -a # macOS/Linux ver # Windows # Error message # Copy full error from terminal ``` ### Support Channels Get real-time help from the community Search these docs first Contact support team ## Prevention Tips ```bash theme={null} npm update -g lua-cli ``` Or check current version: ```bash theme={null} lua --version ``` Always commit `lua.skill.yaml` to git: ```bash theme={null} git add lua.skill.yaml git commit -m "Update skill config" ``` Add to `.gitignore`: ``` .env .env.local ``` Always test locally: ```bash theme={null} lua chat # Test conversationally (sandbox mode) lua test # Optional: Test specific tools lua push # Push when ready lua deploy # Deploy to production ``` ## Still Having Issues? If your issue isn't covered here: 1. **Ask on Discord**: Get real-time help from the community 2. **Email Support**: For urgent issues or account problems Join other Lua builders for real-time help [support@lua.ai](mailto:support@lua.ai) - We're here to help! # Utility Commands Source: https://docs.heylua.ai/cli/utility-commands Quick access commands for admin interface and documentation ## Overview Utility commands provide quick access to the Lua Admin interface, documentation, shell autocomplete setup, and telemetry settings. Full agent state at a glance List organizations and agents Generate shell autocomplete Open admin dashboard Open evaluations dashboard Open documentation Manage usage telemetry List and select the LLM your agent uses Manage governance policies for your agent Update lua-cli to the latest version ## lua status / lua describe Inspect the full state of your agent project in a single command. `lua status` (alias: `lua describe`) shows a comprehensive snapshot of your agent: environment info, authentication, project config, per-primitive sync state, persona drift, backup status, telemetry, and actionable next steps. ```bash theme={null} lua status # human-readable table lua describe # identical output via alias lua status --json # machine-readable JSON for scripting ``` ### What It Shows **Environment** * CLI version and update availability * Node.js version, OS, install method (npm, pnpm, nvm, npx, local) * API base URL and any overriding env vars (`LUA_API_URL`, `LUA_API_KEY`, `LUA_NO_HINTS`, etc.) **Auth** * API key source (env var / credentials file / .env) * Authenticated email, user ID, and organization list * Server reachability status **Project** * Config path (`lua.skill.yaml`) * Agent name and ID * Compiled manifest — how many primitives were found in the last compile **Primitives Sync Table** For each primitive type (skills, webhooks, jobs, preprocessors, postprocessors, MCP servers, devices, device triggers), shows: * Local version (from YAML) * Server version (active) * Sync status: `synced`, `ahead`, `behind`, `not deployed`, or `server only` **Persona / Backup / Telemetry** Quick status indicators with a hint if action is needed. **Next Steps** Actionable hints based on current state (e.g. `lua push backup` if backup is out of sync). ### JSON Output The `--json` flag emits a stable JSON document intended for LLM agents, CI dashboards, and scripting: ```bash theme={null} lua status --json | jq '.auth.email' lua status --json | jq '.primitives[] | select(.kind == "skill") | .diffs' ``` The schema is versioned (`schemaVersion: 1`) so consumers can detect breaking changes. All human-readable progress output is suppressed in JSON mode. ## lua agents List all organizations and agents you have access to for discovery and scripting. View all organizations and agents accessible with your API key. Useful for automation, team management, and discovering available agents. ```bash theme={null} lua agents # List all accessible agents lua agents --json # JSON output for scripting ``` ### What It Shows The command displays all organizations you're a member of and their associated agents: **Organization Information:** * Organization ID * Organization name * Your role (owner, admin, developer) **Agent Information:** * Agent ID * Agent name * Environment (production/staging/sandbox) * Status (active/inactive) ### Example Output ```bash theme={null} $ lua agents ✅ Found 2 organizations with 4 agents Organization: My Company (org_abc123) Role: Owner ├── Customer Support Bot (agent_xyz789) │ Environment: Production │ Status: Active ├── Sales Assistant (agent_def456) │ Environment: Production │ Status: Active Organization: Test Workspace (org_test999) Role: Developer ├── Dev Bot (agent_dev111) │ Environment: Staging │ Status: Active └── Experimental Agent (agent_exp222) Environment: Sandbox Status: Inactive ``` ```bash theme={null} $ lua agents --json ``` ```json theme={null} { "success": true, "organizations": [ { "orgId": "org_abc123", "orgName": "My Company", "role": "owner", "agents": [ { "agentId": "agent_xyz789", "agentName": "Customer Support Bot", "environment": "production", "status": "active", "createdAt": "2025-01-15T10:30:00Z", "lastUpdated": "2025-01-20T14:22:00Z" }, { "agentId": "agent_def456", "agentName": "Sales Assistant", "environment": "production", "status": "active", "createdAt": "2025-01-18T09:15:00Z", "lastUpdated": "2025-01-21T16:45:00Z" } ] }, { "orgId": "org_test999", "orgName": "Test Workspace", "role": "developer", "agents": [ { "agentId": "agent_dev111", "agentName": "Dev Bot", "environment": "staging", "status": "active", "createdAt": "2025-01-10T12:00:00Z", "lastUpdated": "2025-01-19T11:30:00Z" }, { "agentId": "agent_exp222", "agentName": "Experimental Agent", "environment": "sandbox", "status": "inactive", "createdAt": "2025-01-12T15:20:00Z", "lastUpdated": "2025-01-14T10:10:00Z" } ] } ] } ``` ### Options | Option | Description | | -------- | --------------------------------------- | | `--json` | Output as JSON for programmatic parsing | ### Use Cases **Find agent IDs for `lua init` or scripts:** ```bash theme={null} $ lua agents # Copy agent ID for initialization $ lua init --agent-id agent_xyz789 ``` Quickly find the correct agent ID without going to admin dashboard. **See what agents team members have access to:** ```bash theme={null} $ lua agents # Review all accessible agents # Verify permissions are correct # Check which agents are active ``` Audit team access and agent organization. **Parse JSON to automate agent operations:** ```bash theme={null} # Get all production agents lua agents --json | jq '.organizations[].agents[] | select(.environment == "production")' # Count total agents lua agents --json | jq '[.organizations[].agents[]] | length' # Find agent by name lua agents --json | jq '.organizations[].agents[] | select(.agentName == "Sales Assistant")' # List all agent IDs lua agents --json | jq -r '.organizations[].agents[].agentId' ``` Build automation tools and monitoring scripts. **Identify staging/sandbox agents for testing:** ```bash theme={null} # Find all staging agents lua agents --json | jq '.organizations[].agents[] | select(.environment == "staging")' # Check for inactive agents lua agents --json | jq '.organizations[].agents[] | select(.status == "inactive")' ``` Manage different environments effectively. **Validate agent access in pipelines:** ```bash theme={null} #!/bin/bash # Verify agent exists before deployment AGENT_ID="agent_xyz789" AGENTS_JSON=$(lua agents --json) if echo "$AGENTS_JSON" | jq -e ".organizations[].agents[] | select(.agentId == \"$AGENT_ID\")" > /dev/null; then echo "✅ Agent found - proceeding with deployment" lua push all --force --auto-deploy else echo "❌ Agent not accessible - check API key permissions" exit 1 fi ``` Validate environment before deploying. **Deploy to multiple agents programmatically:** ```bash theme={null} # Deploy to all production agents for agent_id in $(lua agents --json | jq -r '.organizations[].agents[] | select(.environment == "production") | .agentId'); do echo "Deploying to $agent_id" cd "/path/to/project/$agent_id" lua push all --force --auto-deploy done ``` Automate multi-agent deployments. ### JSON Parsing Examples **Extract specific information:** ```bash theme={null} # Get organization names lua agents --json | jq -r '.organizations[].orgName' # Get agents in specific org lua agents --json | jq '.organizations[] | select(.orgName == "My Company") | .agents' # Count agents per organization lua agents --json | jq '.organizations[] | {org: .orgName, count: (.agents | length)}' # Find your role in each organization lua agents --json | jq '.organizations[] | {org: .orgName, role: .role}' # List active production agents with details lua agents --json | jq '.organizations[].agents[] | select(.status == "active" and .environment == "production") | {name: .agentName, id: .agentId}' ``` ### Requirements * Must be authenticated (`lua auth configure`) * API key must have valid permissions ### Troubleshooting **Check:** 1. Verify authentication: `lua auth key --force` 2. Ensure API key has permissions 3. Check if you're a member of any organizations **Error:** "No organizations accessible" ```bash theme={null} # Re-authenticate $ lua auth configure # Ask organization owner to add you ``` **Check:** 1. Verify agent exists in admin dashboard 2. Ensure you have access to the agent's organization 3. Check if agent was recently created (may take a moment) **Solution:** ```bash theme={null} # Refresh by re-running $ lua agents ``` **Ensure jq is installed:** ```bash theme={null} # macOS brew install jq # Ubuntu/Debian apt-get install jq # Test echo '{"test": true}' | jq '.' ``` ### Integration with Other Commands ```bash theme={null} # Discover agent → Initialize project → Deploy lua agents # Find agent ID lua init --agent-id agent_xyz789 # Initialize lua push all --force --auto-deploy # Deploy # List agents → Select one → Check production state lua agents --json | jq -r '.organizations[].agents[].agentId' lua production overview # Check specific agent # Automate init for multiple agents for agent_id in $(lua agents --json | jq -r '.organizations[0].agents[].agentId'); do mkdir "project-$agent_id" cd "project-$agent_id" lua init --agent-id "$agent_id" cd .. done ``` ## Non-Interactive Mode for Auth Commands The following commands support `--force` to skip confirmation prompts: ```bash theme={null} # View API key without confirmation lua auth key --force # Logout without confirmation lua auth logout --force # Clear chat history without confirmation lua chat clear --force # Clear specific user's history without confirmation lua chat clear --user user@email.com --force ``` `--user` accepts a user ID, email address, or mobile number and requires `org:manage` for the agent. Organization-admin grants do not cascade to private agents; organization owners and sufficient explicit agent grants retain access under the shared authorization rules. Without `--user`, the command clears only your own history. ## lua completion Generate shell autocomplete scripts for faster command-line workflows. ```bash theme={null} lua completion [shell] # Generate completion script lua completion bash # Generate for Bash lua completion zsh # Generate for Zsh lua completion fish # Generate for Fish ``` Enable tab completion for all Lua CLI commands and arguments! ### What It Does Generates shell-specific completion scripts that enable: * ✅ Tab completion for all commands * ✅ Subcommand suggestions * ✅ Environment option suggestions (sandbox, staging, production) * ✅ Argument completion for push, env, persona, skills * ✅ Context-aware completions ### Supported Shells **Installation** ```bash theme={null} # Add to your ~/.bashrc lua completion bash >> ~/.bashrc source ~/.bashrc # Or for one-time setup echo 'eval "$(lua completion bash)"' >> ~/.bashrc source ~/.bashrc ``` **Test it** ```bash theme={null} lua pu # Completes to: lua push lua push # Shows: skill, persona lua env # Shows: sandbox, staging, production lua persona # Shows: sandbox, staging, production ``` **Installation** ```bash theme={null} # Add to your ~/.zshrc lua completion zsh >> ~/.zshrc source ~/.zshrc # Or for one-time setup echo 'eval "$(lua completion zsh)"' >> ~/.zshrc source ~/.zshrc ``` **Test it** ```bash theme={null} lua # Shows all commands lua skills # Shows: sandbox, staging, production lua auth # Shows: configure, logout, key ``` **Installation** ```bash theme={null} # Create completions directory if it doesn't exist mkdir -p ~/.config/fish/completions # Generate completion file lua completion fish > ~/.config/fish/completions/lua.fish # Completions are automatically loaded ``` **Test it** ```bash theme={null} lua # Shows all commands with descriptions lua push # Shows: skill, persona lua env # Shows: sandbox, staging, production ``` ### Completion Features Tab completion for all Lua CLI commands: ```bash theme={null} lua a # auth, admin lua p # push, persona, production lua s # skills lua e # env lua f # features lua c # compile, completion, chat, channels ``` Context-aware subcommand completion: ```bash theme={null} lua auth # configure, logout, key lua push # skill, persona lua chat # clear lua completion # bash, zsh, fish ``` Environment selection for applicable commands: ```bash theme={null} lua env # sandbox, staging, production lua persona # sandbox, staging, production lua skills # sandbox, staging, production ``` Common flags available for all commands: ```bash theme={null} lua -- # --help, --version lua push -- # --help ``` ### Verification After installation, verify autocomplete is working: ```bash theme={null} # Type this and press TAB lua pu # Should complete to: lua push ``` ```bash theme={null} # Type this and press TAB lua push # Should show options: skill persona ``` ```bash theme={null} # Type this and press TAB lua env # Should show: sandbox staging production ``` ### Troubleshooting **Problem**: Tab completion doesn't work after installation **Solutions:** 1. Restart your terminal or reload shell config: ```bash theme={null} # Bash source ~/.bashrc # Zsh source ~/.zshrc # Fish (automatic) ``` 2. Verify script was added correctly: ```bash theme={null} # Check if completion is in config tail ~/.bashrc # or ~/.zshrc ``` 3. Try explicit installation: ```bash theme={null} eval "$(lua completion bash)" # Or zsh ``` **Problem**: Some commands complete, others don't **Solutions:** 1. Regenerate completion script: ```bash theme={null} lua completion bash > /tmp/lua_completions cat /tmp/lua_completions >> ~/.bashrc source ~/.bashrc ``` 2. Check Lua CLI version: ```bash theme={null} lua --version # Should be v2.6.0 or higher ``` **Problem**: Completions don't work in Fish shell **Solutions:** 1. Verify file location: ```bash theme={null} ls ~/.config/fish/completions/lua.fish ``` 2. Regenerate if missing: ```bash theme={null} mkdir -p ~/.config/fish/completions lua completion fish > ~/.config/fish/completions/lua.fish ``` 3. Restart Fish shell **Problem**: Completions conflict with other tools **Solution:** Remove old completion and reinstall: ```bash theme={null} # Bash/Zsh: Remove old lines from config file vim ~/.bashrc # or ~/.zshrc # Delete old lua completion lines # Fish: Remove old file rm ~/.config/fish/completions/lua.fish # Reinstall lua completion [your-shell] ``` ### Benefits Type less, complete more with tab See available options without docs Autocomplete prevents mistakes Professional CLI experience ### Advanced Usage If you use multiple shells, install for each: ```bash theme={null} # Bash lua completion bash >> ~/.bashrc # Zsh lua completion zsh >> ~/.zshrc # Fish lua completion fish > ~/.config/fish/completions/lua.fish ``` Add to team onboarding: ```bash theme={null} # In your team's setup script echo "Setting up Lua CLI autocomplete..." lua completion bash >> ~/.bashrc source ~/.bashrc echo "✅ Autocomplete enabled" ``` Include in Docker images: ```dockerfile theme={null} # In Dockerfile RUN lua completion bash >> /root/.bashrc ``` ## lua admin Launch the Lua Admin interface in your default browser. ```bash theme={null} lua admin ``` ### What It Opens The Lua Admin Dashboard provides complete control over your agent: **Conversations** * 💬 View conversations in real-time * 📝 Reply to user messages * 📊 Monitor conversation quality * 🔍 Search conversation history * 📈 Analyze user interactions **User Management** * 👥 Add users to your agent * ✏️ Edit user permissions * 🗑️ Remove users * 👀 View user activity * 📊 User analytics **API Keys** * 🔑 Generate new API keys * 👁️ View existing keys * 🗑️ Revoke keys * 📋 Copy keys for development * 🔒 Manage key permissions **Channel Connections** * 📱 WhatsApp integration * 📸 Instagram messaging * ✉️ Email integration * 👍 Facebook Messenger * 💬 Slack integration * 📞 SMS/Twilio * 🌐 Website chat widget * 🔗 Custom integrations **Billing & Subscription** * 💳 View current plan * 📊 Usage metrics * 💰 Billing history * 🔄 Update payment method * 📈 Upgrade/downgrade plan ### Example ```bash theme={null} $ lua admin ✓ Lua Admin Dashboard opened in your browser Dashboard URL: https://admin.heylua.ai Agent ID: agent-abc123 Organization ID: org-xyz789 ``` ### Requirements * Must be authenticated (`lua auth configure`) * Must be in a skill directory (has `lua.skill.yaml`) * Configuration must contain `agent.agentId` and `agent.orgId` ### Use Cases ```bash theme={null} $ lua admin ``` * View live conversations * See how users interact with your agent * Identify areas for improvement * Take over conversations if needed ```bash theme={null} $ lua admin ``` * Add team members * Set permissions (admin, developer, viewer) * Manage API keys per user * Control who can deploy ```bash theme={null} $ lua admin ``` * Connect WhatsApp Business * Set up Instagram messaging * Configure email integration * Add Slack workspace * Enable Facebook Messenger ```bash theme={null} $ lua admin ``` * View conversation metrics * Check response times * Monitor user satisfaction * Track tool usage * Analyze peak times ```bash theme={null} $ lua admin ``` * Review usage this month * Check billing history * Update payment method * Upgrade subscription * Download invoices ### Troubleshooting **Check:** 1. Run `lua auth configure` to authenticate 2. Ensure `lua.skill.yaml` exists (`lua init`) 3. Verify agent ID is in config **Error:** "No Lua CLI authentication found" ```bash theme={null} $ lua auth configure ``` **Check:** 1. Verify you're in correct project directory 2. Check `agentId` in `lua.skill.yaml` 3. Switch to correct agent directory **Check:** 1. Verify you have admin access 2. Check with organization owner 3. Request proper permissions ## lua evals Launch the Lua Evaluations Dashboard in your default browser. ```bash theme={null} lua evals ``` ### What It Opens The Lua Evaluations Dashboard at [https://evals.heylua.ai](https://evals.heylua.ai) provides tools to test and evaluate your agent: **Evaluation Features** * 🧪 Test your agent with predefined scenarios * 📊 View evaluation results and metrics * 📈 Track agent performance over time * 🔍 Identify areas for improvement * ✅ Validate agent responses ### Example ```bash theme={null} $ lua evals ✓ Lua Evaluations Dashboard opened in your browser Dashboard URL: https://evals.heylua.ai Agent ID: agent-abc123 ``` ### Requirements * Must be authenticated (`lua auth configure`) * Must be in a skill directory (has `lua.skill.yaml`) * Configuration must contain `agent.agentId` ### Use Cases ```bash theme={null} $ lua evals ``` * Run predefined test scenarios * Validate agent behavior * Check response quality * Ensure consistency ```bash theme={null} $ lua evals ``` * Monitor evaluation scores * Compare across versions * Identify regressions * Measure improvements ```bash theme={null} $ lua evals ``` * Run evaluations before deployment * Validate production readiness * Document test results * Share with team ### Troubleshooting **Check:** 1. Run `lua auth configure` to authenticate 2. Ensure `lua.skill.yaml` exists (`lua init`) 3. Verify agent ID is in config **Error:** "No Lua CLI authentication found" ```bash theme={null} $ lua auth configure ``` **Check:** 1. Verify you're in correct project directory 2. Check `agentId` in `lua.skill.yaml` 3. Switch to correct agent directory ## lua docs Launch this documentation in your default browser. ```bash theme={null} lua docs ``` ### What It Opens Opens the complete Lua documentation at [https://docs.heylua.ai](https://docs.heylua.ai) **Sections:** * 🏠 Overview and getting started * 📖 Key concepts (Persona, Skills, Tools, Resources) * ⌨️ All CLI commands * 📚 Complete API reference * 💼 11 production-ready demos * 💬 LuaPop chat widget guide ### Example ```bash theme={null} $ lua docs ✓ Lua Documentation opened in your browser Documentation: https://docs.heylua.ai ``` ### Requirements None - works from anywhere ### Use Cases ```bash theme={null} # Forgot a command syntax? $ lua docs # Navigate to CLI Commands ``` ```bash theme={null} # Need API method signature? $ lua docs # Go to API Reference ``` ```bash theme={null} # Need an example for your use case? $ lua docs # Check Demos section ``` ```bash theme={null} # Onboarding new developer? $ lua docs # Share the URL ``` ### Keyboard Shortcut Add to your shell profile for even faster access: ```bash theme={null} # Add to ~/.zshrc or ~/.bashrc alias ld='lua docs' # Usage $ ld # Opens documentation instantly ``` ## lua telemetry Manage whether lua-cli sends usage data. Control whether lua-cli collects usage data to help improve the developer experience. ```bash theme={null} lua telemetry # Show current status lua telemetry on # Enable telemetry lua telemetry off # Disable telemetry lua telemetry status # Show status (same as no argument) ``` ### What Is Collected lua-cli collects usage data to improve the developer experience: | Collected | Not Collected | | ----------------------------------------- | -------------------------------- | | Command name (e.g. `push`, `compile`) | Your code or file contents | | Success or failure | Command arguments or flag values | | Command duration | API keys or secrets | | CLI version, OS, Node.js version | Agent names or custom data | | Account email (for person identification) | | Your data is handled in accordance with our [Privacy Policy](https://heylua.ai/privacy). ### Opting Out You can opt out at any time using either method: ```bash theme={null} lua telemetry off ``` Persists your preference in `~/.lua-cli/telemetry.json`. Re-enable with `lua telemetry on`. ```bash theme={null} # Disable for this session LUA_TELEMETRY=false lua push # Or add to your shell profile to disable permanently echo 'export LUA_TELEMETRY=false' >> ~/.zshrc ``` The environment variable takes highest priority and overrides the saved preference. ### Check Current Status ```bash theme={null} $ lua telemetry Telemetry: enabled Usage: lua telemetry on Enable telemetry lua telemetry off Disable telemetry lua telemetry status Show current setting Or set LUA_TELEMETRY=false in your environment. ``` ### First-Run Notice On first use, lua-cli prints a one-time notice: ``` Lua CLI collects usage data to improve the developer experience. To opt out, run: lua telemetry off Or set: LUA_TELEMETRY=false ``` This notice is shown once and never again. ### CI/CD Environments In CI/CD pipelines, disable telemetry via environment variable — no config file needed: ```bash theme={null} # In your CI environment variables LUA_TELEMETRY=false ``` Or use the `--ci` flag which automatically adjusts CLI behavior for non-interactive environments. ## lua models Manage the LLM model for your agent. The current model is resolved server-first (authoritative) with a fallback to your local compiled artifact. ```bash theme={null} lua models # List all approved models, highlight the one in use lua models list # Same as above lua models list --json # Machine-readable JSON output lua models set # Interactive picker (searchable, provider-grouped) lua models set --model openai/gpt-4o # Non-interactive lua models unset # Remove model — revert to platform default ``` | Option | Description | | ---------------- | ------------------------------------------------------- | | `--model ` | Model code for the `set` action (e.g. `openai/gpt-4o`). | | `--json` | Output `list` as JSON for scripting. | ### Actions | Action | What it does | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `list` (default) | Print all approved models grouped by provider. Highlights the currently configured model. | | `set` | Select a model interactively, or pass `--model` for non-interactive. Writes the model into your local `src/index.ts` and pushes to the server. | | `unset` | Remove the model from source and clear it on the server. Your agent reverts to the platform default. | You can also set the model at agent creation time: `lua init --model openai/gpt-4o`. ## lua governance Manage governance policies for your agent project. Governance wraps tool calls, preprocessors, and postprocessors with runtime enforcement using the governance SDK. ```bash theme={null} lua governance # Interactive governance setup lua governance add # Add governance (SDK or API mode) lua governance remove # Remove governance from agent (local + server) ``` | Action | What it does | | --------- | --------------------------------------------------------------------------------- | | (default) | Interactive setup — prompts for SDK or API mode and required configuration. | | `add` | Add governance to the agent in either SDK mode (in-process) or API mode (remote). | | `remove` | Strip governance enforcement from both local source and the server. | After adding governance, `lua sync` detects drift on governance configuration the same way it does for other primitives. ## lua update Updates `lua-cli` to the latest published version using the same package manager you installed it with (npm, pnpm, or yarn). ```bash theme={null} lua update ``` No options — this is a one-shot command. Pair it with `lua status` to see whether an update is available before running. ## lua channels-phone Phone-specific helper for the `lua channels` family. Used during phone-channel setup and diagnosis. Run `lua channels-phone --help` for the current subcommand set. ```bash theme={null} lua channels-phone --help ``` For the general channel management command, see [`lua channels`](/cli/channels-command). ## lua chat-log-probe Inspect raw chat log records for a session. Useful when debugging an issue reported with a specific thread ID or user — it shows the underlying log entries the platform recorded, in execution order. ```bash theme={null} lua chat-log-probe --help ``` Pair this with `lua logs --type user_message` and `--type agent_response` for the high-level view; use `chat-log-probe` when you need the raw underlying records. ## Quick Comparison | Command | Opens/Generates | Requires Auth | Use For | | ---------------- | ---------------------------- | ------------- | -------------------------------------- | | `lua agents` | List of organizations/agents | ✅ Yes | Discovery, automation, team management | | `lua completion` | Shell completion script | ❌ No | Tab completion, faster workflows | | `lua admin` | Admin dashboard | ✅ Yes | Managing agent, conversations, billing | | `lua evals` | Evaluations dashboard | ✅ Yes | Testing agent, tracking performance | | `lua docs` | Documentation | ❌ No | Reference, examples, learning | | `lua telemetry` | Telemetry settings | ❌ No | Opt in/out of usage data collection | ## Integration with Workflow ### During Development ```bash theme={null} # Need API reference? $ lua docs # Check API section # Continue coding ``` ### During Deployment ```bash theme={null} # Deploy skills $ lua push $ lua deploy # Check admin dashboard $ lua admin # Monitor conversations # Verify deployment working ``` ### When Troubleshooting ```bash theme={null} # Issue with command $ lua docs # Search troubleshooting section # Check production state $ lua admin # View live conversations # Check for errors ``` ## Next Steps Complete command reference Explore the admin dashboard New to Lua? Start here 11 production-ready solutions # Version Command Source: https://docs.heylua.ai/cli/version-command Create, inspect, promote, and roll back atomic versions of your agent Looking for the release model as a whole — how `lua push`, `lua deploy`, and `lua version` fit together? Start with [The release flow, end to end](#the-release-flow-end-to-end) below. ## Overview `lua version` manages **agent versions** — atomic snapshots of your agent's complete deployed state (skills, webhooks, jobs, processors, MCP servers, persona, and model) that you can inspect, diff, and switch between instantly. ```bash theme={null} lua version create --auto-push -m "checkout flow v2" # Push + snapshot in one step lua version list # See all versions lua version diff 1 2 # What changed between v1 and v2? lua version promote 2 # Make v2 live — instant, no re-upload lua version status # What's live vs what's pushed but not live? ``` ## Why versions? A `lua push` updates individual primitives (a skill, a webhook, a persona) one by one. An agent version **pins the exact combination of everything** at a moment in time: which version of every skill, what model, which persona text, which MCP servers and how they were configured. Promoting a version swaps the whole agent state **atomically** — all of it changes at once, with no in-between state where some primitives are old and some are new. Rolling back is just promoting an older version. `lua version promote` is the recommended rollback path. It is instant, atomic, and requires no re-uploading of files. See [`lua version promote`](#lua-version-promote) below and the deprecation notice on [`lua source rollback`](/cli/source-command). ## The release flow, end to end Three commands, three distinct jobs: 1. **`lua push `** stages code. It mints a new immutable version of that primitive — a skill, webhook, job, processor, trigger, persona, voice, MCP config, or model change — but activates nothing. Nothing your users see changes yet. 2. **`lua version create`** freezes a release candidate. It snapshots the latest pushed version of *every* primitive into a single agent version. 3. **`lua version promote `** activates that agent version. Every primitive switches at once — no mixed-version window where, say, a new skill is live against an old persona. This push → create → promote sequence is the recommended release flow. **Execution guarantee:** if a `promote` (or `deploy`) reports success, that exact code serves the very next invocation of the agent. Allow up to roughly a minute for edge caches to catch up globally. ### Where `lua deploy` fits `lua deploy ` (documented in full on the [Skill Management](/cli/skill-management#lua-deploy) page) is the older, single-primitive release command and remains fully supported: * **Once an agent has promoted at least one agent version**, `lua deploy` performs a **scoped promote**: the platform automatically creates and promotes a new agent version identical to the current active one, except for the primitive you just deployed. The deploy is live immediately, and your agent-version history stays consistent — no gap where a primitive changed outside the version timeline. These auto-created versions show up in `lua version list` with a `deploy …` message so you can tell them apart from versions you created explicitly. * **For agents that have never created or promoted an agent version**, `lua deploy` activates the primitive directly, the same way it always has. Either way, use `lua version promote` when you want to switch several primitives together atomically; use `lua deploy` for a fast, single-primitive fix. Sandbox testing (`lua chat` / `lua test`) never touches what's live — it always runs against your local or pushed-but-not-promoted state, never the active agent version. Conversely, deactivating a webhook (or any primitive) stops it serving immediately, independent of version history. ## The three version numbers When you run `lua version create --auto-push`, the output shows three different counters. They track separate things: | Counter | Example output | What it tracks | | ----------------- | -------------------- | ----------------------------------------------------------------------- | | Primitive version | `user-skill v1.0.15` | Semver of one skill/webhook/job — bumped on each push of that primitive | | Source backup | `Backup at v3` | Your project's source-file history (`lua source list`) | | **Agent version** | `Created v2` | **The atomic agent snapshot — what `lua version` manages** | These counters are independent. Pushing a single skill increments that skill's primitive version and creates a new source backup, but does **not** create a new agent version. Only `lua version create` creates a new agent version. ## Statuses Every agent version has one of four statuses: | Status | Meaning | | ------------ | ------------------------------------------------------------------ | | `active` | Currently serving — starred (`*`) in `lua version list` | | `staged` | Snapshotted and awaiting promotion — not yet live | | `superseded` | Was previously active; replaced when another version was promoted | | `deleted` | Soft-deleted; still listed and inspectable, but cannot be promoted | **"staged" does not mean "staging environment."** A staged agent version is unrelated to your push deploy target (staging vs production) and unrelated to git's staging area. Here, "staged" simply means "snapshotted, awaiting promote." ## Commands ### `lua version create` Snapshots the agent's current pushed state as a new `staged` version. | Option | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | `-m ` | A short description stored with the version (e.g. `"checkout flow v2"`). | | `--auto-push` | Push all local changes first, then snapshot. Equivalent to `lua push all` followed by `lua version create`. | | `--commit-hash ` | Associate a specific git commit hash with this version (recorded in the snapshot metadata). | ```bash theme={null} lua version create -m "stable before A/B test" lua version create --auto-push -m "checkout flow v2" ``` If nothing has changed since the last snapshot, the command exits with: ``` No staged changes since the previous version. ``` On success: ``` ✓ Created v2 (staged). Run 'lua version promote v2' to deploy. ``` ### `lua version list` Prints a table of agent versions with columns VERSION, STATUS, CREATED, BY, and MESSAGE. The active version is starred. | Option | Description | | -------------- | ------------------------------------------------------------------------ | | `--all` | Show all versions (overrides `--limit`). | | `--limit ` | Cap output at `n` versions. Must be ≥ 1. Default: 20. | | `--status ` | Filter by status: `active`, `staged`, `superseded`, `deleted`, or `all`. | | `--json` | Output clean JSON — pipeable to `jq` and other tools. | ```bash theme={null} lua version list # recent versions, active starred lua version list --all # full history lua version list --status staged # only staged versions lua version list --json | jq '.[0]' # first entry as JSON ``` ### `lua version status` Shows, per primitive, which version is pinned by the currently active agent version versus which version is sitting locally or already pushed to the server. Anything pushed but not yet part of an active (or promoted) agent version is flagged so you can see at a glance what's waiting to go live. ```bash theme={null} lua version status ``` Reach for this before a release to confirm exactly what a `lua version create` would capture, or after a release to confirm nothing was left behind. ### `lua version show` Shows the full snapshot for a version: per-type primitive counts, model, persona version, creator email, message, and recorded commit hash (if any). Accepts either `2` or `v2` as the version argument. ```bash theme={null} lua version show 2 lua version show v2 ``` `lua version show` reports what is on the **server** for the agent at the time of the snapshot — not what is in your local project directory. A skill that exists server-side but has been removed from your local workspace is still part of the agent and will appear in the snapshot. ### `lua version diff` Shows what changed between two versions: added, removed, and changed primitives by name and version. Persona is compared by **content**, so re-pushing identical persona text shows `(unchanged)`. MCP configuration changes name the specific fields that changed. Model changes are included. | Option | Description | | -------- | -------------------------- | | `--json` | Output diff as clean JSON. | Accepts `2` or `v2` for both arguments. ```bash theme={null} lua version diff 1 2 lua version diff v1 v2 --json | jq '.changed' ``` ### `lua version promote` Instantly activates a version. The previously active version becomes `superseded`. Promoting the already-active version is a graceful no-op. ```bash theme={null} lua version promote 2 # ✓ Promoted v2. Previous active: v1. ``` There is no confirmation prompt — promotion is instant and the previous state is preserved as `superseded`, so you can always promote back. ### `lua version delete` Soft-deletes a version. Deleted versions remain listed and inspectable with `lua version show`, but can no longer be promoted. | Option | Description | | --------- | ----------------------------- | | `--force` | Skip the confirmation prompt. | ```bash theme={null} lua version delete 3 lua version delete 3 --force ``` Guard-rails: * You cannot delete the **active** version. Promote a different version first. * You cannot delete the **only remaining** version. ## Typical workflows ### Release ```bash theme={null} # 1. Check what's pushed but not yet live lua version status # 2. Push everything and snapshot in one step lua version create --auto-push -m "checkout flow v2" # 3. Preview the new version in chat before promoting lua chat --agent-version 2 # 4. Promote when satisfied lua version promote 2 ``` ### Rollback ```bash theme={null} # 1. See what versions exist lua version list # 2. Promote the last-known-good version — instant, no re-upload lua version promote 1 ``` ### Audit ```bash theme={null} # Diff two versions to understand what changed lua version diff 1 2 # Inspect the full snapshot of a specific version lua version show 2 # Or pipe to jq for scripting lua version show 2 --json | jq '.snapshot' ``` ## Git integration When you have run `lua git connect`, every `lua version create` automatically commits your project and tags the commit `lua/v`, where `N` is the new version number. The commit hash is recorded in the version snapshot and visible in `lua version show`. See the [Git Command](/cli/git-command) page for setup instructions, auto-push configuration, and troubleshooting. ## Related * [Skill Management](/cli/skill-management#lua-deploy) — `lua push` and the legacy `lua deploy` command * [Git Command](/cli/git-command) — auto-commit and `lua/v` tagging * [Source Command](/cli/source-command) — source-file history and workspace restore * [Chat Command](/cli/chat-command) — `lua chat --agent-version N` to preview a version # Voice Command Source: https://docs.heylua.ai/cli/voice-command Try LuaVoice agents live in the browser and run voice test suites ## Overview `lua voice` is the entry point for testing voice-enabled agents. You can talk to your agent live in the browser, run automated voice tests against `*.voice.test.ts` files, and inspect which voice primitives are wired into your agent. ```bash theme={null} lua voice # Interactive: pick agent → voice → start browser test lua voice test # Run all *.voice.test.ts files lua voice list # List voice primitives in the manifest ``` ## Subcommands | Action | What it does | | --------- | --------------------------------------------------------------------------------- | | (default) | Interactive live test. Pick an agent, then a voice, then start a browser session. | | `test` | Run `*.voice.test.ts` files via Jest or Vitest. | | `list` | List LuaVoice primitives in the compiled manifest. | ## Live Test Options These apply to the default `lua voice` invocation. | Option | Description | | ------------------ | ---------------------------------------------------------------------------------------------------- | | `--agent ` | Agent name. Skips the agent picker when your project has multiple agents with voices. | | `--voice ` | Voice name. Skips the voice picker when an agent has multiple voices. Defaults to the primary voice. | | `--context ` | Seed initial conversation context as a JSON object. | | `--thread-id ` | Custom thread ID suffix for sandbox API scoping. | ### Live Test Examples ```bash theme={null} # Interactive — pick agent and voice from menus lua voice # Skip the agent picker lua voice --agent support-bot # Pick a specific voice (skips voice picker) lua voice --voice billing-line # Seed initial context — the agent enters the conversation already knowing things lua voice --context '{"orderId":"ABC-123","customerName":"Sam"}' # Scope to a custom thread (for replaying a session later via API) lua voice --thread-id qa-2026-05-18-run-1 ``` ## `lua voice test` — Automated Voice Tests Runs your project's `*.voice.test.ts` files via Jest or Vitest. Lua auto-detects which runner you're using; pass `--runner` to force one. | Option | Description | | ------------------- | ------------------------------------------------------------------------------- | | `--voice ` | Run only `.voice.test.ts` (filename match). | | `--pattern ` | Test path pattern. Default: `\.voice\.test\.`. Overrides `--voice` if both set. | | `--watch` | Re-run tests on file change. | | `--bail` | Stop on first failing test. | | `--runner ` | Force runner: `jest`, `vitest`, or `auto` (default). | ```bash theme={null} lua voice test # All *.voice.test.ts files lua voice test --voice support # Only support.voice.test.ts lua voice test --pattern support # Filter by name pattern (regex) lua voice test --watch # Watch mode lua voice test --bail # Stop on first failure lua voice test --runner vitest # Force Vitest ``` ## `lua voice list` Prints the LuaVoice primitives defined in your compiled manifest. Useful for sanity-checking that all your voice agents and voices are wired up after a compile. | Option | Description | | -------- | ----------------------------- | | `--json` | Output as JSON for scripting. | ```bash theme={null} lua voice list lua voice list --json | jq ``` ## Related * [Persona Command](/cli/persona-command) — channel-aware personas including `voice` branch * [LuaAgent API](/api/luaagent) * [Voice API](/api/voice) — define voices in code with `LuaVoice` # Webhooks Command Source: https://docs.heylua.ai/cli/webhooks-command Manage agent webhook primitives — view, deploy, activate, and subscribe to platform events ## Overview `lua webhooks` manages HTTP webhook primitives defined with `LuaWebhook`. Each webhook is an addressable endpoint your agent exposes for external services (Stripe, Shopify, etc.) to call. The command also manages **event subscriptions** — wiring a webhook to platform events like `message.delivered`, which now fire for every channel your agent sends on. ```bash theme={null} lua webhooks # Interactive management lua webhooks view # List all webhooks lua webhooks list-events # See subscribable event types lua webhooks subscribe --webhook-name paymentHook --event message.delivered ``` For defining webhooks in code, see [LuaWebhook API](/api/luawebhook). ## Subcommands | Action | What it does | | ------------- | ---------------------------------------------------------- | | `view` | List all webhooks defined on the agent. | | `versions` | List every version of a webhook. | | `deploy` | Promote a version to active. | | `activate` | Re-enable a deactivated webhook. | | `deactivate` | Stop the webhook from receiving traffic. Version retained. | | `delete` | Permanently remove a webhook and all its versions. | | `list-events` | Print the catalog of subscribable platform events. | | `subscribe` | Subscribe a webhook to a platform event. | | `unsubscribe` | Remove a webhook's subscription to an event. | ## Options | Option | Description | | ------------------------- | -------------------------------------------------------------------- | | `--webhook-name ` | Webhook name. Required for most non-interactive actions. | | `--webhook-version ` | Version for `deploy`. Pass `latest` for the newest. | | `--event ` | Event type for `subscribe`/`unsubscribe` (e.g. `message.delivered`). | ## Examples ```bash theme={null} # Interactive lua webhooks # List everything lua webhooks view # Promote a version lua webhooks deploy --webhook-name paymentHook --webhook-version 1.0.3 lua webhooks deploy --webhook-name paymentHook --webhook-version latest # Pause and resume lua webhooks deactivate --webhook-name paymentHook lua webhooks activate --webhook-name paymentHook # Delete lua webhooks delete --webhook-name oldHook # Event subscriptions lua webhooks list-events lua webhooks subscribe --webhook-name paymentHook --event message.delivered lua webhooks unsubscribe --webhook-name paymentHook --event message.delivered ``` ## Webhooks vs Triggers Three different things, often confused: | Concept | Source | Manage with | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | **Webhook primitive** | An HTTP endpoint **your agent owns** that external services call. Defined in code with `LuaWebhook` — your `execute` function handles the request and owns the response. | `lua webhooks` | | **Agent trigger** | A URL that **wakes the agent** on an external event — no handler code; the agent turn does the work. Optionally defined in code with [`LuaTrigger`](/api/luatrigger) for verify/filter/transform shaping. | [`lua triggers`](/cli/triggers-command) | | **Integration trigger** | A subscription on a third-party service (Linear, Discord, etc.) that calls into your agent on a specific event. | [`lua integrations webhooks`](/cli/integrations-command) | Use `lua webhooks` for things like "Stripe will call this URL on `payment.succeeded` and my code will handle it." Use `lua triggers` for things like "when this URL is hit, have the agent deal with it." Use `lua integrations webhooks` for things like "wake my agent when a Linear issue is created." ## Event Subscriptions Your agent emits platform events a webhook can subscribe to, so you learn what happened to a message without polling for it. Run `lua webhooks list-events` to see the current catalog. ```bash theme={null} lua webhooks list-events ``` Subscribe a webhook to an event so the webhook fires whenever the event occurs: ```bash theme={null} lua webhooks subscribe --webhook-name deliveryTracker --event message.delivered ``` ### The `message.*` delivery events | Event | Fires when | | ------------------- | ---------------------------------------------------------- | | `message.sent` | The provider handed the message to the recipient's network | | `message.delivered` | It reached the recipient's device | | `message.read` | The recipient opened it | | `message.failed` | It will not arrive. The payload carries `error` | | `message.played` | A WhatsApp voice note was played. WhatsApp only | **These fire for every channel.** They used to be WhatsApp-only. Every channel that sends now writes the same delivery record, and that record is what produces the event, so an agent already subscribed to `message.delivered` starts receiving email and SMS receipts with no change on your side. They fire once per real status change. A provider that redelivers a receipt, or reports one out of order, produces no second event. The payload is a [`DeliveryView`](/api/channels#delivery-status) plus `messageWamid` and `channel`, which the original WhatsApp payload carried and which stay for anything already reading them: ```typescript theme={null} export const deliveryTracker = new LuaWebhook({ name: 'deliveryTracker', execute: async ({ body }) => { const { eventType, payload } = body; if (eventType === 'message.failed') { console.error( `${payload.channel} send ${payload.id} failed:`, payload.error?.category, // 'billing', 'opted_out', 'window_closed', ... payload.error?.title, payload.error?.code // the vendor's own code ); } return { ok: true }; } }); ``` `payload.id` is the same `deliveryId` the send returned, so you can join an event back to the send that produced it, or read the full record with `Channels.getStatus(payload.id)`. The error categories are listed in [Delivery status](/api/channels#error-categories). ## Request Verification A webhook can carry a signing secret. Set `secret` on the `LuaWebhook` definition and Lua rejects any call to that webhook without a valid `x-lua-signature: sha256=` header with **401**, before your code runs. ```typescript theme={null} new LuaWebhook({ name: 'payment', description: 'Handle payment events', secret: 'a-long-random-string', execute: async (event) => ({ received: true }), }); ``` The secret is applied on every `lua push webhook`, so **rotation is a re-deploy**: change the value, push, and update the sender. Set `secret: ''` and push to turn verification off. There is no CLI command that prints a webhook's secret — `lua webhooks view` never shows it. See [Verify Requests](/overview/webhooks#verify-requests) for the signing recipe. ## Common Workflow ```bash theme={null} # Edit your webhook in src/webhooks/payment.ts, then: lua push webhook # Build + upload lua webhooks versions --webhook-name payment # Confirm version is on server lua webhooks deploy --webhook-name payment --webhook-version latest lua logs --type webhook --name payment --limit 20 # Verify traffic ``` ## Related * [LuaWebhook API](/api/luawebhook) * [Triggers Command](/cli/triggers-command) — agent triggers (paste-anywhere URLs) * [Integrations Command](/cli/integrations-command) — third-party triggers * [Logs Command](/cli/logs-command) * [Test Command](/cli/skill-management#lua-test) — `lua test webhook` for local testing # Workflows Command Source: https://docs.heylua.ai/cli/workflows-command Manage workflows and their runs from the CLI - list, deploy, start, watch, approve, signal, cancel, retry, replay, archive and inspect Job-tier steps ## Overview `lua workflows` manages workflow definitions and runs. Definitions are pushed with `lua push workflow` and activated with `lua workflows deploy`; runs are started, watched and steered with the run verbs. ```bash theme={null} lua workflows # Interactive: list, run locally, start, list runs lua workflows list # Workflows on the agent lua workflows deploy outreach -v latest # Make the newest version live lua workflows start outreach --input @leads.json --follow lua workflows runs --workflow outreach --status failed lua workflows status --steps lua workflows approve --approval --decision approve ``` For writing workflows see [Authoring](/workflows/authoring). For the run model behind these verbs see [Runs and events](/workflows/runs-and-events). ## Usage ``` lua workflows [action] [target] [extra] [options] ``` * `target` is the workflow name (or id) for `list`, `view`, `versions`, `deploy`, `activate`, `deactivate`, `start`, `run`, `env-overlay` and `delete`; a run id for every other verb. `-i ` / `-r ` are the equivalent options. * `extra` is the signal name for `signal ` and the step id for `job-logs `. ### Subcommands | Action | What it does | | ------------------------- | --------------------------------------------------------------------------------------------------- | | `list` | List workflows on the agent (`--all` includes dynamic ones). | | `view` | Show one workflow: status, active version, schedule, output visibility, env overlay keys, versions. | | `versions` | List every version of a workflow. | | `deploy` | Make a version active (`-v ` or `latest`). | | `activate` / `deactivate` | Enable or pause a workflow's schedules and triggers. `activate -v ` also deploys that version. | | `start` | Start a run. | | `run` | Run a workflow locally (alias of `lua test workflow`; no API call). | | `runs` | List runs. | | `status` | Show one run (and its steps with `--steps`). | | `watch` | Stream a run's events until it ends or waits for a person. | | `cancel` | Request (or force) a cancel. | | `resume` | Resume a step parked by `ctx.suspend()`. | | `retry-step` | Re-arm a failed, parked or billing-held step. | | `approve` | Approve or deny an approval. | | `signal` | Deliver a signal. | | `replay` | Replay a run locally against the compiled artifact. | | `logs` | Print a run's progress and log events. | | `delete` | Delete a workflow. | | `delete-run` | Erase a terminal run. | | `env-overlay` | Show which `env.template()` keys a version carries and whether each is present. | | `archive-runs` | Export terminal runs as evidence bundles to a directory. | | `workspace` | Show or release a run's Job-tier workspace. | | `jobs` | List a run's Job-tier steps. | | `job-logs` | Print a Job-tier step's container log. | ### Common options | Option | Description | | ------------------------------ | ------------------------------------------------------------------------------- | | `-i, --workflow-name ` | Workflow name or id (instead of the positional target). | | `-r, --run-id ` | Run id (instead of the positional target). | | `-v, --workflow-version ` | Version for `deploy`, `activate`, `env-overlay` and `start` (`latest` allowed). | | `--json` | Print the raw `{ success, data }` envelope. Pipe to `jq`. | ### Exit codes | Code | Meaning | | ---- | ---------------------------------------------------------------------------------------------- | | `0` | Success. | | `1` | Server or API failure. | | `2` | Usage error. | | `3` | Not found. | | `4` | The run ended `failed` or `timed_out` (also a replay divergence). | | `5` | The run ended `cancelled` or `abandoned`. | | `6` | The run is gated awaiting consent. | | `7` | `--timeout` reached while the run was still live. | | `8` | The run is parked on a person or a gate (approval, input, signal, exception, billing, budget). | ## Definitions ### `lua workflows list` | Option | Description | | ------- | -------------------------------------------- | | `--all` | Include dynamic (server-composed) workflows. | Columns: Name, Status, Active version, Versions, Form (`static` / `dynamic`), Id. ### `lua workflows view ` Prints the workflow's id, status, active version, schedule, output visibility (roles, users, owner bypass), env overlay keys, and a versions table (version, form, topology, graph hash, created). ### `lua workflows versions ` The versions table alone. ### `lua workflows deploy -v ` Publishes a version as the active one. On an agent under versioning this is a scoped promote: a new agent version is recorded and printed as `agentVersion`. Without `-v` the newest version is deployed. ```bash theme={null} lua workflows deploy outreach -v 1.0.3 lua workflows deploy outreach -v latest ``` ### `lua workflows activate ` / `deactivate ` Enable or pause the workflow's schedules and triggers. `activate -v ` is the same as `deploy`. ### `lua workflows delete ` | Option | Description | | --------- | --------------------------------------------------------------------------------------------------------------------- | | `--yes` | Skip the confirmation prompt. | | `--force` | Cancel in-flight runs first. Without it a workflow with runs in flight is refused (409 `RUNS_IN_FLIGHT` with counts). | Remove the file from `src/workflows/` (and its entry in `lua.skill.yaml`) afterwards to keep the project in sync. ### `lua workflows env-overlay [-v ]` Lists every `env.template()` key on the version, where it resolved from, and whether it is present in the agent environment. Values are never printed. Exits `1` when a key is missing. ## Starting and running ### `lua workflows start ` | Option | Description | | ------------------------------ | -------------------------------------------------------------------------------------- | | `--input ` | Run input (default `{}`). | | `--idempotency-key ` | Reusing a key returns the original run (`idempotent replay`). | | `--correlation-key ` | Caller-chosen, non-unique key for finding and signalling the run later. | | `--tag ` | Repeatable, at most 10. | | `--budget-credits ` | Run budget in credits. | | `--wait ` | Server long-poll, 0..55 s: returns the settled run when it finishes inside the window. | | `--follow` | Attach `watch` after the start. | | `-v, --workflow-version ` | Pin a version instead of the active one. | Prints `Run · ` and, without `--follow`, the watch and status commands to run next. A `gated` start exits `6`; a `concurrencyPolicy: 'forbid'` workflow with a run in flight prints the blocking run id and exits `1`. ```bash theme={null} lua workflows start outreach --input '{"leads":[]}' --follow lua workflows start ticket-plan --input @ticket.json --idempotency-key ticket-plan:TP-12 --tag triage ``` ### `lua workflows run ` Runs the workflow locally after compiling the project - the same as `lua test workflow --name `. No run is created on the server. | Option | Description | | --------------------------- | ------------------------------------------------------------------------------------------------- | | `--input ` | Run input. | | `--step-output ` | Complete a step with this output (repeatable). | | `--approve ` | Pre-answer an approval (repeatable). | | `--deny ` | Pre-deny an approval (repeatable). | | `--signal ` | Pre-supply a `waitForSignal` payload (repeatable). | | `--from-run ` | Seed completed steps from a real run. | | `--force` | Seed from a run whose graph differs. | | `--record ` | Record agent and tool outputs as fixtures. | | `--fixtures ` | Replay recorded fixtures. | | `--step-wall ` | Per-step wall in seconds (default 600). | | `--job-wall ` | Virtual wall for `tier: 'job'` steps; splits at 14 400 s segments, above 86 400 is a usage error. | | `--ledger-out ` | Write the in-memory ledger as JSON. | | `--agents ` | Fake agent steps (default) or call the dev API. | | `--now ` | Virtual clock start. | | `--park ` | Simulate a platform-fault park of a step (repeatable). | | `--fast-retries` | Collapse retry backoff waits to 0. | | `--real-time` | Actually wait on sleeps and backoffs. | | `--artefacts-dir ` | Back `ctx.artefacts.*` on disk. | | `--env ` | Local `env.template()` overlay (repeatable; a missing key is a usage error). | | `--max-ticks ` | Script form: tick cap (default 64). | Interactive runs prompt on stdin for any approval, input or signal you did not pre-answer. ```bash theme={null} lua workflows run outreach --input @leads.json --step-output draftEmail=@draft.json --approve reviewDrafts ``` ## Runs ### `lua workflows runs` | Option | Description | | ------------------------- | ----------------------------------------------------------------- | | `--workflow ` | Filter by workflow. | | `--status ` | Filter by status. | | `--correlation-key ` | Filter by correlation key. | | `--tag ` | Filter by tag (repeatable). | | `--limit ` | Page size. | | `--cursor ` | Page cursor (printed at the end of a page). | | `--sort ` | `-createdAt` (default), `createdAt`, `-durationMs`, `durationMs`. | Columns: Run, Status, Trigger, Created, Correlation, Tags. ### `lua workflows status ` | Option | Description | | ---------- | ----------------------------------------------------------------------------------- | | `--steps` | Fetch the full run and print a per-step table (Step, Kind, Status, Attempt, Error). | | `--strict` | Exit `4` / `5` by terminal status instead of `0`. | Prints the run's status, workflow and version, trigger, correlation key and tags, timings, lineage, gate (with the action that clears it), failure reason and output. A run whose outputs are restricted prints `restricted` instead of the output. ### `lua workflows watch ` Streams the run's events over SSE and prints one line per event. Reconnects from the last event id when the stream drops. Stops at the terminal event (exit `0`, `4` or `5`) or as soon as the run waits for a person or a gate (exit `8`), printing the verb that resumes it. | Option | Description | | --------------- | ------------------------------------------------------------------------------- | | `--after ` | Replay from this sequence number. | | `--timeout ` | Give up after `s` seconds while the run is still live (exit `7`). | | `--events` | Print raw frames as JSON (`{ id, event, data }`) instead of the formatted line. | `step.throttled` events are collapsed into one counter line. Press Ctrl-C to detach without affecting the run. ### `lua workflows logs ` Fetches the run's events once (polling form, up to 500) and prints the progress and lifecycle ones: `step.progress` (your `ctx.log` messages), `*.started`, `*.completed`, `*.failed`, `*.skipped`, `run.log`. | Option | Description | | --------------- | ---------------------------- | | `--step ` | Only this step's events. | | `--follow` | Switch to `watch`. | | `--since ` | Window (accepted; see note). | ### `lua workflows cancel ` | Option | Description | | ----------------- | ----------------------------------------------------- | | `--reason ` | Recorded on the run. | | `--force` | Force cancel once the server says force is available. | Prints the verdict and the next action (`cancel again` or `force available at ...`). ### `lua workflows resume --step ` | Option | Description | | ---------------------- | -------------------------------------------------------- | | `--step ` | The suspended step. Required. | | `--data ` | The `resumeData` (must match the step's `resumeSchema`). | Refused with a hint when the step is an approval (`use approve`) or a signal wait (`use signal`). A repeated resume prints who already resumed it. ### `lua workflows retry-step --step ` | Option | Description | | --------------- | -------------------------- | | `--step ` | The parked step. Required. | | `--note ` | Recorded with the retry. | Re-arms a step that failed past its retries, parked on an exception gate, or sits on a billing hold (the only way out of a billing park after topping up). 409 `STEP_NOT_PARKED` when the step is running, pending or already re-armed; `RUN_TERMINAL` when the run ended. ### `lua workflows approve --approval ` | Option | Description | | ---------------------------- | ------------------------------------------------------------------------------------------------------ | | `--approval ` | The approval id (`wfa_...`). Required. | | `--decision ` | Default `approve`. | | `--note ` | The approver's note (the workflow reads it as `note`). | | `--edit ` | An edited payload (small inline edits). | | `--fingerprint ` | The payload fingerprint you looked at. Mandatory with `--edit`; a stale one is 409 `PAYLOAD_MISMATCH`. | Find the approval id with `lua workflows status --json` (`data.suspensions[].suspend.approvalId`) or in the desktop card. `STEP_UP_REQUIRED` means the approval must be given from the desktop with a fresh login. ```bash theme={null} lua workflows approve wfr_3f2... --approval wfa_9c1... --decision approve --note "Go ahead" lua workflows approve wfr_3f2... --approval wfa_9c1... --decision deny ``` ### `lua workflows signal ` | Option | Description | | ------------------------- | ----------------------------------------------------------- | | `--payload ` | The signal payload (validated against the wait's `schema`). | | `--dedupe-key ` | Makes a redelivery a no-op. | Prints whether the signal was consumed by a step, parked until a step waits for it, or was a duplicate. ```bash theme={null} lua workflows signal wfr_3f2... review --payload '{"ok":true}' --dedupe-key review:42 ``` ### `lua workflows replay --local` Re-derives the run from the compiled artifact in the current project and compares each step's recorded output with the local derivation. A differing graph hash is reported first. Exit `4` on a divergence. Only `--local` is available; pass `-i ` when several compiled workflows could match. ### `lua workflows delete-run ` Erases a terminal run - outputs, artefacts and journal. Prompts unless `--yes`. A live run is refused (`cancel it first`). ## Archiving ### `lua workflows archive-runs --since --out ` Exports every terminal run in the window as an evidence bundle, verifies each download against its manifest hash, writes `.zip` files and an `archive-index.ndjson`, and skips runs already archived with a matching hash. | Option | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `--since ` | Window start (`8d`, `36h`, `90m` or an ISO timestamp). Required. | | `--out ` | Destination directory. Required. | | `--until ` | Window end. | | `--workflow ` | Only this workflow's runs. | | `--tag ` | Only runs with this tag (repeatable). | | `--concurrency ` | Parallel exports, 1..5 (default 2). | | `--no-inputs` | Exclude run inputs from the bundles. | | `--no-artefacts` | Exclude artefacts from the bundles. | | `--retention-days ` | Your organisation's run retention (default 90). `--since` must be inside retention minus the 7-day export TTL, otherwise `ARCHIVE_WINDOW_TOO_OLD`. | | `--connection ` | Storage connection for `s3://` / `gs://` sinks - not available in this build; archive to a local directory and sync it. | Exit `3` when the window holds no terminal runs, `4` when any export failed or was deferred (rate-limited or still pending). ## Job tier ### `lua workflows workspace ` Shows the run's workspace: kind and backend, repo and ref, branch and head, size used, files changed, TTL, worktree arms (branch, head, pushed, merged, conflicts) and any error. | Option | Description | | --------------- | -------------------------------------------------------------------------- | | `--release` | Release the volume now. Refused with the step id while a step is using it. | | `--note ` | Recorded with the release. | ### `lua workflows jobs ` Lists the run's `tier: 'job'` steps: Step, Status, Attempt, Size class, Pod phase, Segment, Heartbeat, Error. ### `lua workflows job-logs ` | Option | Description | | --------------- | ------------------------------------------ | | `--attempt ` | Attempt to read (default latest). | | `--tail ` | Log lines, 1..2000 (default 200). | | `--follow` | Poll every 5 s until the step is terminal. | Prints a header (attempt, status, pod phase, size class, segment, harness, pod name, last heartbeat, error) followed by the log lines. ```bash theme={null} lua workflows job-logs wfr_3f2... implement --tail 500 --follow ``` ## Push, deploy and versions Workflows follow the same release flow as every other primitive: 1. **`lua push workflow`** compiles the project and mints a new immutable version of each changed workflow (`--name ` for one; aliases `workflows` and `wf`). Missing `env.template()` keys abort the push before anything is sent. `lua push all` does **not** include workflows - push them explicitly. `--auto-deploy` publishes the version straight after the push. 2. **`lua workflows deploy -v latest`** activates the version. On an agent under versioning this records a scoped promote - a new agent version identical to the current one except for this workflow - and prints its number. 3. **`lua version`** snapshots pin workflow versions alongside skills, webhooks and jobs, so `lua version promote ` rolls a workflow back with everything else. `lua version create` after a push captures the newest pushed workflow versions. ```bash theme={null} lua push workflow --name outreach lua workflows versions outreach lua workflows deploy outreach -v latest lua workflows start outreach --input @leads.json --follow ``` ## Related * [Workflows](/overview/workflows) - the concept * [Workflows Quick Start](/workflows/quick-start) * [Authoring](/workflows/authoring) and [Job tier](/workflows/job-tier) * [Runs and events](/workflows/runs-and-events) - the run model these verbs act on * [Version Command](/cli/version-command) - agent versions and rollback * [Push & Deploy](/cli/skill-management#lua-push) # API Keys Source: https://docs.heylua.ai/concepts/api-keys Legacy, scoped personal, and device credentials ## Overview Lua accepts existing legacy API keys and typed credentials. Choose the credential class for the client that uses it: `api_` followed by 32 hex characters. Created before scoped keys existed. Act with the owner's full permissions in every organization the owner belongs to. `api_.`. Personal keys with an explicit role on one or more organizations or agents. They never exceed the owner's current access. Typed credentials bound to one exact agent, one exact device name, and selected device operations. Legacy and scoped personal keys use the same bearer-token request format. Device credentials use the device client and device protocol fields. They do not grant general personal API access. ## Legacy keys If your key was created before scoped keys shipped, nothing about it changes. Legacy keys: * Act with the owner's full permissions, in **every** organization the owner belongs to. * Keep working indefinitely. There is no deprecation date, forced rotation, or automatic expiry for legacy keys. Existing integrations can keep using them. New CLI email login creates a renewable user session instead of another API key. ## Scoped keys A scoped key is a **personal** key: it belongs to one member, and it carries an explicit role on each organization or agent it's granted against — the same kind of role a human member holds on that resource in the admin dashboard. Two rules govern what a scoped key can do: A scoped key can never do more than its owner can. Its effective role on a resource is capped at the owner's current role there, even if the key was originally granted a broader role. If the owner loses access to an organization, any key of theirs scoped to that organization loses access too. Keys the owner holds on their other organizations keep working normally. The role picker in the dashboard (**Settings → API Keys**) shows the current set of roles you can grant, since it's the same roster used for human org members. Scope each key to the organizations or agents it actually needs — see [Best practices](#best-practices). ## Interactive login and scoped credentials Use Email login for interactive work on a developer workstation: ```bash theme={null} lua auth configure ``` Choose **Email** and confirm the OTP. The CLI stores a renewable first-party session and reads your current access when each command runs. It does not create an API key. Each project's `lua.skill.yaml` selects its agent. For CI, a direct HTTP integration, or another client that needs a separately managed credential, create a scoped key under **Settings → API Keys**: ```bash theme={null} lua admin # Or visit https://admin.heylua.ai ``` Manage your own keys or, if you are an organization administrator, keys for another member. Grant a role on one or more organizations or agents. A key created for another member cannot exceed that member's role. Defaults to never. Set a date if the key is for a temporary integration. Lua shows the full secret once. Store it in a secret manager or `.env` file. You cannot view it again after you leave the page. Never commit an API key to version control or share it publicly. ## Device credentials New Node.js installations and custom protocol clients use a device credential instead of a personal scoped key. Provision it for one exact agent, one exact device name, and only the `commands`, `triggers`, or `assets.upload` operations that the device needs. See [Device credentials](/devices/credentials) for the endpoint, client setup, and compatibility behavior. ## Managing a key Scoped personal keys and device credentials use the same rotation, suspension, reactivation, and revocation lifecycle. Replaces the key's secret while keeping its role and grants intact. Only the key's owner can rotate it. The new secret is shown exactly once. The old secret stops working as the change propagates, within about a minute. Temporarily disables a key without losing its configuration. A suspended key fails authentication after the change propagates. Its role and grants stay unchanged. Permanently disables a key. Either the key's owner or an org admin can revoke it. Revocation is one-way — a revoked key can't be reactivated, only replaced with a new one. It takes effect within about a minute everywhere. ## Using a key Usage is identical for legacy and scoped keys: ```bash theme={null} Authorization: Bearer YOUR_API_KEY ``` See the [HTTP API reference](/channels/http-api). ```bash theme={null} export LUA_API_KEY=your-api-key # or lua auth configure --api-key your-api-key ``` See [Authentication](/cli/authentication). ```typescript theme={null} new DeviceClient({ deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, agentId: 'your-agent-id', deviceName: 'your-device-name', }); ``` New Node.js installations use a [device credential](/devices/credentials) with an exact agent, device name, and operation binding. Existing `apiKey` and `api_key` configurations remain supported for non-dotted legacy keys indefinitely. ## Errors | Status | Meaning | | ------ | -------------------------------------------------------------------------- | | `401` | The key is invalid, expired, suspended, or revoked. | | `403` | The key is valid, but its role doesn't allow that action on that resource. | A `403` on a scoped key usually means the key needs a broader role, or a grant on an additional organization or agent — check **Settings → API Keys** in the dashboard. ## Best practices Don't reuse a single key across unrelated integrations. If one is compromised or needs rotating, a dedicated key limits the blast radius and makes it obvious what to revoke. Give a key only the role it needs on a resource, not a broader one "to be safe." A key capped at the right role can't be misused for actions it was never meant to perform. If an integration only ever needs one agent, scope the key to that agent instead of the whole organization. It keeps the key from reaching agents it has no business touching. Contractor access, a one-off migration script, a demo environment — anything with a known end date should get an expiry instead of relying on someone remembering to revoke it later. ## Next Steps Configure the CLI with an API key Call your agent directly over HTTP # Channel-aware Prompts Source: https://docs.heylua.ai/concepts/channel-aware-prompts Customize persona and skill instructions for voice vs text channels ## What Are Channel-aware Prompts? Channel-aware prompts let you customize your agent's `persona` and skill `context` based on whether the user is interacting via **voice** or **text**. This is rarely needed — the platform handles most channel-specific behavior automatically. But when you have genuinely different communication needs (e.g., conversational tone on voice, structured lists on text), this feature lets you optimize for each channel. If you need to ask "should I say this differently on voice vs text?", you probably don't need channel-aware prompts. Most agents should keep a simple string persona and context. ## When to Use Channel-aware Prompts Use the polymorphic form **only if you have a measurably different need**: * Conversational tone on voice, structured formatting on text * Voice-specific instructions (e.g., "avoid markdown") * Text-specific instructions (e.g., "use ::: list-item blocks") * Minor tweaks or polish * The same concept explained differently * Most agents (keep it simple) ## Syntax Both `persona` and skill `context` accept the polymorphic form: ```typescript theme={null} // String form (recommended) persona: "You are Alex, a friendly customer support agent" // Object form (channel-aware) persona: { base: "You are Alex, a customer support agent.", voice: "Speak naturally and conversationally.", text: "Use ::: list-item blocks when showing options." } ``` ### Fields * **`base`** — Shared across all channels (optional) * **`voice`** — Voice-specific additions (optional, ignored on text) * **`text`** — Text-specific additions (optional, ignored on voice) **Strict semantics:** If a channel-specific key isn't set, it is not rendered. There is **no fallback** across channels. **Validation:** The object must have at least one non-empty field. Empty objects (`{}`), empty strings (`""`), and objects whose only present fields are empty (`{ base: "" }`) are rejected at push time — pass `undefined` or omit the field entirely if you want no persona / context. ## Examples ### Example 1: Customer Support Agent ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; const agent = new LuaAgent({ name: 'support-agent', // Base persona shared across channels persona: { base: `You are Alex, customer support specialist for TechCorp. You help users resolve issues and find solutions. Core responsibilities: - Answer product questions - Troubleshoot issues - Process refunds and returns - Escalate complex issues`, voice: `Speak naturally and conversationally. Use short, clear sentences. Ask one question at a time.`, text: `For longer explanations, break them into ::: list-item blocks. Use the ::: actions component to show next steps. Be concise but thorough in writing.` }, skills: [supportSkill] }); ``` On **voice**, the agent hears: > "You are Alex... Speak naturally and conversationally. Use short, clear sentences..." On **text**, the agent sees: > "You are Alex... For longer explanations, break them into ::: list-item blocks..." ### Example 2: Hotel Booking Agent ```typescript theme={null} const bookingSkill = new LuaSkill({ name: 'hotel-booking', description: 'Book hotel rooms', context: { base: `Help guests find and book hotel rooms. Key tools: - check_availability: Find rooms by date and guest count - create_reservation: Book a confirmed room - cancel_reservation: Cancel existing booking`, voice: `When confirming details on a call, read them back naturally: "So that's checking in on March 15, checking out on March 18, for 2 guests?" Ask about special requests naturally (early check-in, late checkout, etc.)`, text: `After searching for availability, show results using ::: horizontal-list-item blocks with images for each room. Include price, room type, and amenities. Confirmation steps should use ::: actions component.` }, tools: [checkAvailabilityTool, createReservationTool, cancelReservationTool] }); ``` ### Example 3: Analytics Report Skill ```typescript theme={null} const reportSkill = new LuaSkill({ name: 'analytics-reports', description: 'Generate and share analytics reports', context: { base: `Provide analytics insights using generate_report and share_report tools. Always ask for date range and metrics before generating.`, voice: `Speak results conversationally: "Your revenue this month is $42,000, which is up 15% from last month. The top product is Widget X with 2,500 units sold."`, text: `Display reports using ::: list-item components for each metric. Show charts using ::: images component. Use ::: links to export or share the report.` }, tools: [generateReportTool, shareReportTool] }); ``` ## Migration: From String to Object If you start with a simple string and later realize you need channel-specific behavior, the migration is straightforward: **Before:** ```typescript theme={null} const agent = new LuaAgent({ name: 'my-agent', persona: 'You are a helpful assistant.' }); ``` **After:** ```typescript theme={null} const agent = new LuaAgent({ name: 'my-agent', persona: { base: 'You are a helpful assistant.', voice: 'Speak conversationally.', text: 'Use structured formatting.' } }); ``` All existing string personas continue to work unchanged. ## Common Patterns ### Pattern 1: Base + Voice Only When you only need voice customization (text uses the base): ```typescript theme={null} persona: { base: "Core persona description", voice: "Additional voice-specific instructions" // text is omitted — uses base on text channels } ``` On voice: `base + voice`\ On text: `base` (text field is not rendered) ### Pattern 2: Base + Text Only When you only need text customization (voice uses the base): ```typescript theme={null} context: { base: "General skill context", text: "Formatting instructions for text channels" // voice is omitted — uses base on voice channels } ``` On voice: `base` (voice field is not rendered)\ On text: `base + text` ### Pattern 3: Base + Both Channels Full customization for both channels: ```typescript theme={null} persona: { base: "Shared core personality", voice: "Voice-specific tone", text: "Text-specific formatting" } ``` On voice: `base + voice`\ On text: `base + text` ## Best Practices Write a single string persona first. Add channel-aware behavior only when you discover a real need. Put the core agent personality and responsibilities in `base`. Use `voice` and `text` only for channel-specific nuances. On voice, focus on natural language flow, short sentences, and oral clarity. On text, leverage the ::: components (list-item, actions, links, etc.) for visual clarity. Remember: the platform handles 95% of channel-specific behavior for you (voice tone, text formatting rules, etc.). Only use this feature for cases where you genuinely need different core instructions. When using channel-aware prompts, always test in both voice and text to ensure they work as intended. ## API Reference See the `persona` field in [LuaAgent](/api/luaagent) and `context` field in [LuaSkill](/api/luaskill) for complete type signatures and examples. ## Next Steps Deep dive into creating effective personas Learn about skill design and context ::: component syntax for text channels How voice calls work in Lua # Environment Variables Source: https://docs.heylua.ai/concepts/environment-variables Managing configuration and secrets securely ## Overview Environment variables allow you to configure your skills without hardcoding sensitive information like API keys. **Never hardcode secrets in your code!** Always use environment variables. ## Using Environment Variables ### Import the env Function ```typescript theme={null} import { env } from 'lua-cli'; export class SendEmailTool implements LuaTool { async execute(input: any) { const apiKey = env('SENDGRID_API_KEY'); const fromEmail = env('FROM_EMAIL') || 'noreply@example.com'; if (!apiKey) { throw new Error('SENDGRID_API_KEY not configured'); } // Use the API key... } } ``` ## Setting Environment Variables **Quick Setup**: Use `lua env` command for an interactive interface to manage environment variables in both sandbox and production environments. ```bash theme={null} lua env ``` There are two ways to set environment variables, loaded in this priority order: Variables from your system environment ```bash theme={null} export API_KEY=value ``` Variables from `.env` file in project root ```bash theme={null} # .env API_KEY=value ``` `.env` file overrides system environment variables. For production, use `lua env` command to manage variables on the server. ## Method 1: .env File (Recommended for Development) Create a `.env` file in your project root: ```bash theme={null} # External API Keys STRIPE_API_KEY=sk_test_abc123 SENDGRID_API_KEY=SG.xyz789 OPENAI_API_KEY=sk-abc123 # Configuration API_BASE_URL=https://api.example.com ENABLE_DEBUG=false MAX_RETRIES=3 ``` ### .gitignore **Always add `.env` to `.gitignore`!** ```bash theme={null} # .gitignore .env .env.local .env.*.local ``` ### .env.example Create a `.env.example` file to document required variables: ```bash theme={null} # .env.example STRIPE_API_KEY=your_stripe_key_here SENDGRID_API_KEY=your_sendgrid_key_here API_BASE_URL=https://api.example.com ``` Commit `.env.example` to git, but never commit `.env`! ## Method 2: Interactive Command Use the `lua env` command for an interactive interface: ```bash theme={null} lua env ``` Choose Sandbox (.env file) or Production (API) Add, update, delete, or view variables through interactive menu Variables are saved to `.env` (sandbox) or API (production) See full documentation for the `lua env` command ## Example: External API Integration ### Stripe Payment Tool ```typescript theme={null} import { LuaTool, env } from 'lua-cli'; import { z } from 'zod'; export default class CreatePaymentTool implements LuaTool { name = "create_payment"; description = "Create a payment link for checkout"; inputSchema = z.object({ amount: z.number().positive(), currency: z.string().default('USD'), description: z.string() }); async execute(input: z.infer) { // Get API key from environment const stripeKey = env('STRIPE_API_KEY'); // Validate it exists if (!stripeKey) { throw new Error('STRIPE_API_KEY not configured. Please set it in .env or lua.skill.yaml'); } // Use the key const response = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { 'Authorization': `Bearer ${stripeKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ 'line_items[0][price_data][currency]': input.currency, 'line_items[0][price_data][unit_amount]': (input.amount * 100).toString(), 'line_items[0][price_data][product_data][name]': input.description, 'line_items[0][quantity]': '1', 'mode': 'payment', 'success_url': 'https://example.com/success', 'cancel_url': 'https://example.com/cancel' }) }); const session = await response.json(); return { paymentUrl: session.url, sessionId: session.id }; } } ``` ### .env File ```bash theme={null} STRIPE_API_KEY=sk_test_51Abc123... ``` ## Best Practices ```bash theme={null} # Good STRIPE_API_KEY=... SENDGRID_API_KEY=... WEATHER_API_KEY=... # Bad KEY1=... SECRET=... TOKEN=... ``` ```typescript theme={null} const maxRetries = parseInt(env('MAX_RETRIES') || '3'); const apiUrl = env('API_BASE_URL') || 'https://api.example.com'; ``` ```typescript theme={null} const apiKey = env('STRIPE_API_KEY'); if (!apiKey) { throw new Error('STRIPE_API_KEY is required'); } ``` Create `.env.example` with all required variables and example values ```typescript theme={null} // BAD! const apiKey = 'sk_test_abc123'; // GOOD! const apiKey = env('API_KEY'); ``` Always add `.env` to `.gitignore` ```typescript theme={null} // BAD! console.log('API Key:', apiKey); // GOOD! console.log('API Key:', apiKey ? '***' : 'not set'); ``` ## Common Patterns ### Pattern: Required Variable ```typescript theme={null} const apiKey = env('REQUIRED_KEY'); if (!apiKey) { throw new Error('REQUIRED_KEY environment variable is not set'); } ``` ### Pattern: Optional with Default ```typescript theme={null} const apiUrl = env('API_URL') || 'https://api.example.com'; const timeout = parseInt(env('TIMEOUT') || '5000'); const debug = env('DEBUG') === 'true'; ``` ### Pattern: Validation ```typescript theme={null} const apiKey = env('API_KEY'); if (apiKey && !apiKey.startsWith('sk_')) { throw new Error('API_KEY must start with sk_'); } ``` ### Pattern: Environment-Specific Configuration ```typescript theme={null} const environment = env('NODE_ENV') || 'development'; const apiKey = environment === 'production' ? env('PROD_API_KEY') : env('DEV_API_KEY'); ``` ## Testing with Environment Variables ### In lua test Environment variables are automatically loaded from `.env` when you run: ```bash theme={null} lua test ``` ### In lua chat Environment variables are loaded based on selected mode: ```bash theme={null} lua chat ``` * **Sandbox mode**: Uses `.env` file * **Production mode**: Uses production variables from server ## Troubleshooting **Problem**: `env('MY_VAR')` returns `undefined` **Solutions**: 1. Check spelling in `.env` file 2. Ensure `.env` is in project root 3. Restart `lua chat` if running 4. For production, use `lua env` to verify variables on server **Problem**: Updated `.env` but changes not visible **Solution**: Restart the CLI command: ```bash theme={null} # Stop current process (Ctrl+C) lua chat # Start again ``` **Problem**: Works locally but not when deployed **Solution**: Use `lua env` to manage production variables: ```bash theme={null} lua env # Select Production mode # Add production API keys ``` ## Example Project Structure ``` my-skill/ ├── .env # Local secrets (not in git) ├── .env.example # Template (in git) ├── .gitignore # Contains .env ├── lua.skill.yaml # Production config ├── src/ │ ├── index.ts │ └── tools/ │ └── MyTool.ts # Uses env('VAR_NAME') └── package.json ``` ## Next Steps Use environment variables in a real project See payment integration example # Platform APIs Source: https://docs.heylua.ai/concepts/platform-apis Built-in APIs for common operations ## Overview Lua CLI provides built-in APIs for common operations, so you don't have to build everything from scratch. ```typescript theme={null} import { Agents, User, Data, CDN, Products, Baskets, Orders, Templates, Channels } from 'lua-cli'; ``` Invoke the current agent or another agent through the full chat pipeline Per-user persistent storage for state, preferences, and workflow data Custom data with vector search File upload and retrieval E-commerce product catalog Shopping cart management Order processing Template messaging (WhatsApp) Send outbound messages on any connected channel ## Agents API **Agent invocation from runtime code** — call the current agent or another agent through its full processing pipeline (billing, persistence, skills, tools, preprocessors, postprocessors) from any sandbox code. Use this inside Lua primitives; reserve `/chat/generate` and `/chat/stream` for external apps and services. ```typescript theme={null} import { Agents } from 'lua-cli'; // Simplified — returns plain text const reply = await Agents.invoke('support-agent', 'What is the refund policy?'); // Full options — returns structured output with usage and tool info const result = await Agents.invoke('legal-agent', { prompt: 'Review this clause for risks.', threadId: 'contract-456', systemPrompt: 'Be concise.', }); ``` ### Use Cases ```typescript theme={null} // Inside a tool — caller's user is used automatically const analysis = await Agents.invoke('data-analyst-agent', { prompt: `Analyse this dataset: ${JSON.stringify(data)}`, threadId: `analysis-${jobId}`, }); return { report: analysis.text }; ``` ```typescript theme={null} // Inside a webhook — pass userId from the event payload execute: async (event) => { const { customerId, orderId } = event.body; await Agents.invoke('notification-agent', { prompt: `Order ${orderId} has shipped. Notify the customer.`, userId: customerId, // provide the user to run as }); return { notified: true }; } ``` ```typescript theme={null} // Inside a LuaJob — no ambient user, no conversation history stored execute: async (job) => { const result = await Agents.invoke('digest-agent', { prompt: 'Generate the daily digest.', // userId omitted — invocation runs without user identity }); return { digest: result.text }; } ``` Complete Agents API documentation ## User API **Persistent per-user storage** that survives across conversations and sessions. Store any data — onboarding state, workflow progress, preferences, cart contents, verification results — as direct properties on the user object. ```typescript theme={null} import { User } from 'lua-cli'; const user = await User.get(); // Write any property — it persists across conversations user.onboardingStep = 'verified'; user.plan = 'enterprise'; user.collectedData = { company: 'Acme', role: 'admin' }; user.lastInteraction = new Date().toISOString(); await user.save(); // Read it back anytime (even days later, in a different conversation) const returning = await User.get(); console.log(returning.onboardingStep); // 'verified' console.log(returning.plan); // 'enterprise' ``` ### Example: Stateful Onboarding Tool ```typescript theme={null} export class CheckOnboardingTool implements LuaTool { name = "check_onboarding"; description = "Check and advance the user's onboarding progress"; inputSchema = z.object({}); async execute(input: any) { const user = await User.get(); const step = user.onboardingStep || 'not_started'; if (step === 'complete') { return { message: `Welcome back, ${user.name}! You're all set up.` }; } return { currentStep: step, completedSteps: user.completedSteps || [], message: `You're on step: ${step}. Let's continue!` }; } } ``` Complete User API documentation ## Data API Store and retrieve custom data with semantic search capabilities. ### Key Features * 🗄️ **Custom Collections**: Store any JSON data * 🔍 **Vector Search**: Semantic similarity search * 📊 **Filtering**: Query by field values * 📄 **Pagination**: Handle large datasets ### Quick Example ```typescript theme={null} import { Data } from 'lua-cli'; // Create with search indexing await Data.create('movies', { title: 'Inception', director: 'Christopher Nolan', year: 2010 }, 'Inception Christopher Nolan 2010 sci-fi thriller dreams'); // Semantic search const results = await Data.search('movies', 'mind-bending thriller', 10, 0.7); // Finds "Inception" even though query doesn't contain exact words! ``` ### Use Cases ```typescript theme={null} await Data.create('kb_articles', { title: 'How to reset password', content: 'Step by step guide...', category: 'Account' }, `password reset account help`); const results = await Data.search('kb_articles', 'forgot my password', 10, 0.7); ``` ```typescript theme={null} await Data.create('customers', { name: 'John Doe', company: 'Acme Corp', notes: 'Interested in enterprise plan' }, `John Doe Acme Corp enterprise`); const results = await Data.search('customers', 'enterprise customers', 20, 0.6); ``` ```typescript theme={null} await Data.create('products', { name: 'Wireless Headphones', description: 'Noise cancelling...', category: 'Electronics' }, `wireless headphones noise cancelling bluetooth`); const results = await Data.search('products', 'best headphones for travel', 5, 0.75); ``` Filtering large collections with `Data.get(collection, filter)`? Declare the filtered fields when storing — `Data.create(c, doc, { index: ['field'] })` — so the platform maintains an index for them. See [Data API — Indexes](/api/data#indexes). Complete Data API documentation ## CDN API Upload and retrieve files from the Lua CDN. ### Key Features * 📤 **Upload Files**: Store any file type * 📥 **Retrieve Files**: Get files by ID * 🖼️ **Image Optimization**: Automatic WebP compression * 🤖 **AI Integration**: Use with AI for image analysis ### Quick Example ```typescript theme={null} import { CDN } from 'lua-cli'; // Upload a file const file = new File([buffer], 'document.pdf', { type: 'application/pdf' }); const fileId = await CDN.upload(file); // Retrieve by ID const retrievedFile = await CDN.get(fileId); console.log(retrievedFile.name); // 'document.pdf' ``` ### Use Cases ```typescript theme={null} // Store a document const doc = new File([content], 'report.pdf', { type: 'application/pdf' }); const fileId = await CDN.upload(doc); // Save reference await Data.create('documents', { title: 'Q4 Report', fileId: fileId, uploadedAt: new Date().toISOString() }); ``` ```typescript theme={null} // Get image from CDN const file = await CDN.get(imageFileId); const buffer = Buffer.from(await file.arrayBuffer()); // Analyze with AI const analysis = await AI.generate( 'Describe this image.', [{ type: 'image', image: buffer, mediaType: file.type }] ); ``` ```typescript theme={null} // Save user upload const file = new File([userData], 'profile.jpg', { type: 'image/jpeg' }); const fileId = await CDN.upload(file); // Update user profile await User.update({ avatarFileId: fileId }); ``` Complete CDN API documentation ## Products API Manage e-commerce product catalog. ```typescript theme={null} import { Products } from 'lua-cli'; // Search products const products = await Products.search('laptop'); // Create product await Products.create({ name: 'MacBook Pro', price: 1999.99, category: 'Computers', sku: 'MBP-14-001', inStock: true }); // Get by ID const product = await Products.getById('product_abc123'); ``` Complete Products API documentation ## Baskets API Shopping cart management for e-commerce. ```typescript theme={null} import { Baskets, BasketStatus } from 'lua-cli'; // Create basket const basket = await Baskets.create({ currency: 'USD', metadata: { source: 'web' } }); // Add items await Baskets.addItem(basket.id, { id: 'product_xyz', price: 29.99, quantity: 2, SKU: 'PROD-001' }); // Checkout const order = await Baskets.placeOrder({ shippingAddress: {...}, paymentMethod: 'stripe' }, basket.id); ``` ### Basket Statuses Currently being used for shopping Converted to an order User left without checkout TTL exceeded Complete Baskets API documentation ## Orders API Order creation and management. ```typescript theme={null} import { Orders, OrderStatus } from 'lua-cli'; // Create order const order = await Orders.create({ basketId: 'basket_abc123', data: { shippingAddress: {...}, paymentMethod: 'stripe' } }); // Update status await Orders.updateStatus(OrderStatus.FULFILLED, order.id); // Get orders const userOrders = await Orders.get(OrderStatus.PENDING); ``` ### Order Statuses Created, not yet confirmed Confirmed, being processed Completed and delivered Cancelled by user or system Complete Orders API documentation ## Templates API Send template messages across different channels. Currently supports WhatsApp templates. (Not to be confused with [Agent Templates](/marketplace/agent-templates), the marketplace's full agent blueprints.) ```typescript theme={null} import { Templates } from 'lua-cli'; // List WhatsApp templates const result = await Templates.whatsapp.list(channelId, { search: 'order' }); // Get specific template const template = await Templates.whatsapp.get(channelId, templateId); // Send template message await Templates.whatsapp.send(channelId, templateId, { phoneNumbers: ['+447551166594'], values: { body: { customer_name: 'John', order_number: '12345' } } }); ``` ### Use Cases * 📦 **Order notifications** - Shipping updates, delivery confirmations * 📅 **Appointment reminders** - Healthcare, salon, service bookings * 🎉 **Marketing campaigns** - Promotions, announcements * 🔐 **Authentication** - OTP codes, verification messages Complete Templates API documentation ## Channels API Send outbound messages on any channel your agent is connected to — WhatsApp, SMS, email, web chat, and more — from a tool, job, webhook, or trigger. Each send is recorded to the recipient's conversation thread so your agent stays coherent across replies. ```typescript theme={null} import { Channels } from 'lua-cli'; // Free-form message (choose the channel) await Channels.send({ channel: 'whatsapp', to: { userId: 'user_123' }, text: 'Your order has shipped!' }); // Rich email await Channels.email.send({ to: { email: 'customer@example.com' }, subject: 'Receipt', html: '

Thanks for your order!

' }); // Approved WhatsApp template (start or re-open a conversation) await Channels.whatsapp.sendTemplate({ to: { phoneNumber: '+14155552671' }, templateName: 'order_update', languageCode: 'en_US' }); ``` ### Use Cases * 🔔 **Proactive notifications** — order updates, reminders, follow-ups * ⏰ **Scheduled outreach** — pair with [Jobs](/api/jobs) for time-based sends * ✅ **Event confirmations** — send from a webhook the moment something happens Complete Channels API documentation ## Combining APIs Most real-world applications combine multiple APIs: ### E-commerce Flow Example ```typescript theme={null} export class QuickCheckoutTool implements LuaTool { async execute(input: any) { // 1. Search for product const products = await Products.search(input.productName); const product = products.products[0]; // 2. Create basket const basket = await Baskets.create({ currency: 'USD' }); // 3. Add product await Baskets.addItem(basket.id, { id: product.id, price: product.price, quantity: input.quantity }); // 4. Create order const order = await Baskets.placeOrder({ shippingAddress: input.address, paymentMethod: 'stripe' }, basket.id); return { orderId: order.id, total: basket.common.totalAmount }; } } ``` ### CRM with Custom Data Example ```typescript theme={null} export class CreateCustomerTool implements LuaTool { async execute(input: any) { // Get current user const user = await User.get(); // Create customer record const customer = await Data.create('customers', { name: input.name, email: input.email, company: input.company, createdBy: user.email, createdAt: new Date().toISOString() }, `${input.name} ${input.company} ${input.email}`); // Log interaction await Data.create('interactions', { customerId: customer.id, type: 'created', notes: 'Initial contact', timestamp: new Date().toISOString() }); return { customerId: customer.id }; } } ``` ## API Comparison | API | Best For | Key Feature | | ------------- | --------------------------------------------- | --------------------------------------------------------- | | **Agents** | Self-invocation and multi-agent orchestration | Full pipeline invocation with auth & billing | | **User** | Per-user persistent state | Schemaless key-value store across sessions | | **Data** | Custom schemas | Vector search | | **CDN** | File storage | Upload/retrieve files | | **Products** | E-commerce | Pre-built catalog | | **Baskets** | Shopping carts | TTL & status | | **Orders** | Order tracking | Status workflow | | **Templates** | Bulk template sends | WhatsApp templates by channel ID | | **Channels** | Agent-initiated messaging | Outbound on any connected channel, recorded to the thread | ## Next Steps Complete API documentation with examples See working examples using all APIs Step-by-step tutorial Learn about configuration management # Skills & Tools Source: https://docs.heylua.ai/concepts/skills-and-tools Understanding the core building blocks of Lua CLI ## What is Lua? Lua is a platform for building AI agents with custom capabilities. Think of it like: ``` AI Agent (ChatGPT-like) └── Skills (Custom capabilities you build) └── Tools (Specific functions the AI can call) ``` ## Skills A **skill** is a collection of related tools that give your AI agent specific capabilities. ### Example: Coffee Shop Skill ```typescript theme={null} import { LuaSkill } from "lua-cli"; const coffeeSkill = new LuaSkill({ name: "coffee-shop-skill", description: "Coffee shop assistant with menu, ordering, and loyalty features", context: ` This skill helps customers of Java Junction Coffee Shop. - Use show_menu to display available drinks and food - Use create_order to take customer orders - Use check_loyalty_points to show rewards balance - Use redeem_reward to apply loyalty discounts Always mention daily specials. Ask about size preferences for drinks. `, tools: [ new ShowMenuTool(), new CreateOrderTool(), new CheckLoyaltyTool(), new RedeemRewardTool() ] }); ``` ### Skill Properties Unique identifier for the skill (e.g., "coffee-shop-skill") Brief description (1-2 sentences) of what the skill does Detailed instructions for the AI on when and how to use the tools. This is critical for proper tool selection! Array of tool instances to include in the skill Optional async function that determines if the whole skill is available ```typescript theme={null} async condition(): Promise ``` When it returns `false` the skill's tools can't be called **and** its name, context, and tool names are left out of the agent's prompt — the agent doesn't know the capability exists. Use it for tiering and entitlements, where the existence of a feature is itself sensitive. Fail-closed: a condition that throws or times out hides the skill. See [Conditional Skills](/api/luaskill#conditional-skills) and [Skill condition vs tool condition](/api/luaskill#skill-condition-vs-tool-condition). ### Writing Good Context The `context` field guides the AI's decision-making. Write it like instructions to a smart assistant: ```typescript Good Context theme={null} context: ` This skill manages customer orders for a coffee shop. Tool Usage: - show_menu: Use when customers ask what's available. Returns drinks and food items. - create_order: Use when taking an order. Confirm items and sizes first. - modify_order: Use to add/remove items. Ask which item to modify. - finalize_order: Use when order is confirmed. Returns total and wait time. Guidelines: - Always ask about size for drinks (small/medium/large) - Mention daily special when showing menu - Confirm total before finalizing - Ask about dietary restrictions for food items ` ``` ```typescript Bad Context theme={null} context: "A skill with tools for stuff" ``` ## Tools A **tool** is a single function that the AI can call to accomplish a specific task. ### Anatomy of a Tool ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class GetWeatherTool implements LuaTool { // Unique identifier (lowercase, alphanumeric, hyphens, underscores) name = "get_weather"; // Clear description of what the tool does description = "Get current weather conditions for any city"; // Zod schema defining valid inputs inputSchema = z.object({ city: z.string().describe("City name (e.g., 'London', 'Tokyo')"), units: z.enum(['metric', 'imperial']).optional().default('metric') }); // Async function that implements the logic async execute(input: z.infer) { // input is automatically validated and typed const { city, units } = input; // Call weather API const weather = await fetchWeather(city, units); // Return structured data return { temperature: weather.temp, condition: weather.condition, city: weather.location }; } } ``` ### Tool Properties Unique identifier using only: `a-z`, `A-Z`, `0-9`, `-`, `_` Examples: `get_weather`, `create-product`, `sendEmail123` Clear, concise description (1 sentence) of what the tool does Helps the AI understand when to use this tool Zod schema that validates inputs at runtime Provides automatic validation and TypeScript types Async function that implements the tool's logic ```typescript theme={null} async execute(input: any): Promise ``` Optional async function that determines if the tool should be available ```typescript theme={null} async condition(): Promise ``` Use for premium features, feature flags, channel-specific tools, or user-specific availability. A hidden tool is left out of the prompt's tool list, but the skill's name and context stay — the agent can still tell the user the capability exists. To hide a capability entirely, gate the skill instead: [Skill condition vs tool condition](/api/luaskill#skill-condition-vs-tool-condition). Access the current channel via `Lua.request.channel` and raw webhook data via `Lua.request.webhook?.payload` - see [Lua API](/api/lua). ### Sharing Tool Logic Across Agents When you build multiple agents that share behaviour — same search backend, same external API, same auth flow — factor the shared parts into a base class and have each agent declare a thin subclass that overrides only the bits that differ. ```typescript theme={null} // packages/shared/SearchKnowledgeBaseTool.ts import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export abstract class SearchKnowledgeBaseTool implements LuaTool { name = 'search_knowledge_base'; description = "Semantic search over the agent's knowledge base."; inputSchema = z.object({ query: z.string() }); // Override this in subclasses to point at a specific backend. ragSearchPath: string = '/default/search'; async execute(input: { query: string }) { const res = await fetch(`https://api.example.com${this.ragSearchPath}`, { method: 'POST', body: JSON.stringify({ q: input.query }), }); return res.json(); } } ``` ```typescript theme={null} // agents/blackship/src/tools/BlackshipSearch.ts import { SearchKnowledgeBaseTool } from '@my-org/shared'; export class BlackshipSearchTool extends SearchKnowledgeBaseTool { name = 'search_blackship'; ragSearchPath = '/blk/search'; } // agents/whitestar/src/tools/WhitestarSearch.ts export class WhitestarSearchTool extends SearchKnowledgeBaseTool { name = 'search_whitestar'; description = 'Semantic search over Whitestar product docs.'; ragSearchPath = '/wht/docs'; } ``` ```typescript theme={null} import { LuaSkill } from 'lua-cli'; import { BlackshipSearchTool } from './tools/BlackshipSearch'; export default new LuaSkill({ name: 'support', description: 'Support tools', context: 'Use search_blackship to find product info.', tools: [BlackshipSearchTool], // pass the class itself, not new BlackshipSearchTool() }); ``` How it works: * The `lua` compiler walks the full `extends` chain. A leaf that extends a shared base which itself extends `LuaTool` is detected as a tool. * Field initializers on the leaf (`ragSearchPath = '/blk/search'`) override the parent's defaults. When `execute()` runs and reads `this.ragSearchPath`, it sees the leaf's value — JavaScript's normal inheritance semantics. * The leaf inherits the parent's `inputSchema`, `description`, and `execute` unless it overrides them. No copy-paste. Don't pass per-reference constructor arguments inside a `tools: [...]` array: ```typescript theme={null} // ❌ The string argument is silently dropped — the compiler builds // one shared tool artifact per project, reused across every reference. tools: [new BlackshipSearchTool('/blk/search')] ``` The compiler will warn (`lua/constructor-args-dropped`) if it sees this pattern. Use a subclass with a field override instead. ## Skills vs Tools **Single Function** ```typescript theme={null} // One specific thing class GetWeatherTool { // Gets weather for a city } ``` **Collection of Tools** ```typescript theme={null} // Multiple related things new LuaSkill({ tools: [ new GetWeatherTool(), new GetForecastTool(), new GetAlertstool() ] }); ``` ## How It Works User asks the AI: "What's the weather in London?" AI reads the skill's `context` and determines `get_weather` tool is appropriate AI provides input: `{ city: "London" }` Input is validated against `inputSchema` The `execute` function runs with validated input Calls weather API and returns structured data AI uses the tool's output to form a natural language response "The weather in London is 15°C and cloudy." ## Tool Naming Best Practices * `search_products` * `create_order` * `cancel_booking` * `get_user_profile` * `do_search` (too generic) * `process` (unclear) * `tool1` (not descriptive) * `get data` (spaces not allowed) ## Creating Your Agent You configure your entire agent using `LuaAgent`: ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; // Create your skill const coffeeSkill = new LuaSkill({ name: "coffee-shop-skill", description: "Coffee shop assistant", tools: [/* your tools */] }); // Create agent export const agent = new LuaAgent({ name: "coffee-assistant", persona: `You are a friendly barista at Java Junction Coffee Shop. Your role: - Help customers browse the menu - Take and confirm orders - Suggest popular items - Answer questions about ingredients Communication style: - Warm and welcoming - Enthusiastic about coffee - Patient and helpful - Always mention daily specials`, skills: [coffeeSkill] }); ``` Use `LuaAgent` to configure persona, welcome message, and skills together in one unified configuration. ### Why LuaAgent? The new LuaAgent pattern provides: * ✅ **Single source of truth** - All agent config in one place (code, not YAML) * ✅ **Better organization** - Clear separation of persona, skills, webhooks, jobs * ✅ **Auto-sync** - CLI auto-manages `lua.skill.yaml` (do not edit manually) * ✅ **More features** - Support for webhooks, jobs, preprocessors, postprocessors ## Multiple Skills in One Project You can organize tools into multiple skills and add them all to your agent: ```typescript theme={null} import { LuaAgent, LuaSkill } from 'lua-cli'; // Product browsing skill const catalogSkill = new LuaSkill({ name: "product-catalog-skill", description: "Product browsing and search", tools: [ new SearchProductsTool(), new GetProductTool() ] }); // Shopping skill const shoppingSkill = new LuaSkill({ name: "shopping-skill", description: "Shopping cart management", tools: [ new CreateBasketTool(), new AddItemTool() ] }); // Order fulfillment skill const orderSkill = new LuaSkill({ name: "order-skill", description: "Order creation and tracking", tools: [ new CreateOrderTool(), new TrackOrderTool() ] }); // Create agent with all skills export const agent = new LuaAgent({ name: "ecommerce-assistant", persona: "You are a helpful e-commerce shopping assistant...", skills: [catalogSkill, shoppingSkill, orderSkill] }); ``` ### When to Use Multiple Skills * ✅ Tools serve different purposes (e.g., "HR Skill" vs "Sales Skill") * ✅ Different teams own different skills * ✅ Skills have different deployment schedules * ✅ Skills need different permissions * ✅ Tools work together closely * ✅ Small to medium number of tools (\< 20) * ✅ All tools deploy together ## Example Patterns ### Pattern: CRUD Skill ```typescript theme={null} const dataSkill = new LuaSkill({ name: "data-management-skill", description: "Complete CRUD operations", tools: [ new CreateTool(), new ReadTool(), new UpdateTool(), new DeleteTool(), new SearchTool() ] }); ``` ### Pattern: Workflow Skill ```typescript theme={null} const checkoutSkill = new LuaSkill({ name: "checkout-skill", description: "Complete purchase workflow", tools: [ new CreateBasketTool(), // Step 1: Create cart new AddItemsTool(), // Step 2: Add items new ReviewCartTool(), // Step 3: Review new ApplyDiscountTool(), // Step 4: Apply discounts new ProcessPaymentTool() // Step 5: Complete ] }); ``` ### Pattern: Integration Skill ```typescript theme={null} const integrationSkill = new LuaSkill({ name: "external-integrations-skill", description: "Third-party service integrations", tools: [ new SendEmailTool(), // SendGrid new ProcessPaymentTool(), // Stripe new GetWeatherTool(), // Weather API new TrackShipmentTool() // Shipping API ] }); ``` ## Next Steps Learn about built-in APIs for users, products, and data Follow a step-by-step tutorial Explore 30+ working examples Complete LuaSkill API documentation # Development Workflows Source: https://docs.heylua.ai/concepts/workflows Best practices for developing, testing, and deploying skills ## Development Lifecycle Set up environment variables ```bash theme={null} lua env ``` Add API keys and configuration for sandbox Define your agent's personality ```bash theme={null} lua persona ``` Edit persona in sandbox mode Build and test your skills locally ```bash theme={null} lua chat ``` Use sandbox mode for testing Test conversational flows and individual tools ```bash theme={null} lua chat # Primary testing lua test # Optional: Test specific tools ``` Upload your skill version to the server ```bash theme={null} lua push ``` Test in sandbox environment before production ```bash theme={null} lua chat ``` Choose sandbox mode when prompted Make your skill available to all users ```bash theme={null} lua deploy ``` ## Local Development Workflow ### Starting Development Start by syncing with the server to get any remote changes: ```bash theme={null} # Check for drift between server and local code lua sync # Test individual tools lua test # Test conversational flows lua chat ``` **lua test** - Test tools one at a time with specific inputs **lua chat** - Interactive conversation testing in sandbox or production ### The Development Loop Edit files in `src/tools/`: ```typescript theme={null} // src/tools/MyTool.ts export default class MyTool implements LuaTool { name = "my_tool"; description = "Updated description"; async execute(input: any) { // Your changes here } } ``` Test individual tools: ```bash theme={null} lua test ``` * Select tool from list * Enter test inputs * Verify output Test conversational flows: ```bash theme={null} lua chat ``` * Choose sandbox mode * Chat naturally with agent * Verify tool selection and execution Repeat: Edit → Test → Refine Test until satisfied with results ### Testing Features Test individual tools with specific inputs Test conversational flows and tool selection Test with local changes before deploying Verify deployed changes work correctly ## Testing Workflow ### Interactive Tool Testing Test individual tools with specific inputs: ```bash theme={null} lua test ``` Choose from list of available tools Provide values based on tool's schema See execution results or errors Fix issues and test again ### Conversational Testing Test how the AI uses your tools in natural conversation: ```bash theme={null} lua chat ``` Select **Sandbox mode** to test with your local changes. **Test Scenarios:** Test the ideal user journey * "Show me products" * "Add laptop to cart" * "Checkout" Test unusual but valid inputs * Empty results * Maximum values * Optional parameters Test invalid inputs * Missing required fields * Invalid data types * Out of range values Test complex workflows * Create → Add → Update → Delete * Search → Select → Checkout ## Version Management ### Semantic Versioning Use semantic versioning: `MAJOR.MINOR.PATCH` ```yaml theme={null} # lua.skill.yaml (version is the ONLY field you should manually edit) skills: - name: my-skill version: 1.2.3 ``` The `lua.skill.yaml` file is auto-managed by the CLI. The **version number** is the only field you should manually edit when preparing a new release. All other fields are managed automatically. **When to increment:** Bug fixes and minor improvements * Fixed error handling * Updated descriptions * Performance improvements New features, backward compatible * Added new tool * New optional parameters * Enhanced functionality Breaking changes * Removed tool * Changed required parameters * Renamed tools ### Pushing Versions ```bash theme={null} # Increment version in lua.skill.yaml (only field you should edit): # skills: # - name: my-skill # version: 1.1.0 # Push to server lua push ``` Each push creates a new version that can be deployed independently. ## Advanced Agent Features Beyond skills and tools, lua-cli supports webhooks, jobs, and message processing: ### LuaAgent Configuration ```typescript theme={null} import { LuaAgent, LuaSkill, LuaWebhook, LuaJob, PreProcessor, PostProcessor } from 'lua-cli'; export const agent = new LuaAgent({ name: "my-agent", persona: "You are a helpful assistant...", // Skills with tools skills: [skill1, skill2], // HTTP endpoints for external events webhooks: [stripeWebhook, shopifyWebhook], // Scheduled tasks jobs: [dailyReportJob, cleanupJob], // Message filtering before agent preProcessors: [profanityFilter, rateLimiter], // Response formatting after agent postProcessors: [addDisclaimer, addBranding] }); ``` ### Webhooks Receive events from external services: ```typescript theme={null} import { LuaWebhook } from 'lua-cli'; const paymentWebhook = new LuaWebhook({ name: 'payment-webhook', execute: async (event) => { if (event.type === 'payment.succeeded') { // Handle payment confirmation } } }); ``` **Test webhooks:** ```bash theme={null} lua test # Select: Webhook → payment-webhook ``` ### Jobs Schedule automated tasks: ```typescript theme={null} import { LuaJob, User } from 'lua-cli'; const dailyReportJob = new LuaJob({ name: 'daily-report', metadata: { userId: 'user_abc123' // who to notify — pre-defined jobs have no conversation context }, schedule: { type: 'cron', expression: '0 9 * * *' // Every day at 9 AM }, execute: async (job) => { // Pre-defined LuaJob runs outside any conversation, so look the user up by ID. // (jobInstance.user() is only available on dynamic jobs created via the Jobs API.) const user = await User.get(job.metadata.userId); await user.send([{ type: 'text', text: 'Your daily report is ready!' }]); } }); ``` ### PreProcessors Filter messages before they reach your agent: ```typescript theme={null} import { PreProcessor } from 'lua-cli'; const profanityFilter = new PreProcessor({ name: 'profanity-filter', description: 'Filter inappropriate content', execute: async (user, messages, channel) => { const text = messages.map(m => m.type === 'text' ? m.text : '').join(' '); if (containsProfanity(text)) { return { action: 'block', response: "Please keep the conversation respectful." }; } return { action: 'proceed' }; } }); ``` ### PostProcessors Format responses after agent generation: ```typescript theme={null} import { PostProcessor } from 'lua-cli'; const addDisclaimer = new PostProcessor({ name: 'add-disclaimer', description: 'Add legal disclaimer to responses', execute: async (user, message, response, channel) => { return { modifiedResponse: response + "\n\n_This is AI-generated content._" }; } }); ``` **Learn more:** * [LuaAgent API](/api/luaagent) * [LuaWebhook API](/api/luawebhook) * [LuaJob API](/api/luajob) * [PreProcessor API](/api/preprocessor) * [PostProcessor API](/api/postprocessor) ## Deployment Workflow ### Sandbox Testing Before deploying to production, test in sandbox: ```bash theme={null} # Push latest version lua push # Test in sandbox lua chat # Select sandbox mode when prompted # Verify everything works ``` ### Deploying to Production When ready, deploy to all users: ```bash theme={null} lua deploy ``` Choose which version to deploy from list Confirm the deployment (shows warning) Version is deployed to production Test with real users or in production chat **Deployment is immediate!** If `lua deploy` reports success, that exact code serves the very next invocation of the agent (allow up to roughly a minute for edge caches to catch up). All users will get the new version right away. `lua deploy` activates one primitive at a time. Once your agent has an agent version already promoted, `lua deploy` performs a **scoped promote**: it creates and promotes a new agent version identical to the current one except for the primitive you just deployed, so your agent-version history stays consistent. For releases that touch several primitives at once, prefer [`lua version create` + `lua version promote`](/cli/version-command) — it snapshots and atomically activates everything together, with no mixed-version window. Sandbox testing (`lua chat`, `lua test`) never affects what's live. ## Best Practices ### Development Build one tool at a time * Create basic version * Test thoroughly * Add complexity gradually Test changes quickly * Use `lua test` for tool logic * Use `lua chat` for conversations * Iterate based on results Don't just test happy paths * Invalid inputs * Empty results * Error conditions ### Version Control ```bash .gitignore theme={null} # Always ignore .env .env.local node_modules/ dist/ .lua/ # Keep these .env.example lua.skill.yaml src/ ``` ```bash Commit Messages theme={null} # Good commit messages git commit -m "feat: add search functionality to products" git commit -m "fix: handle empty basket in checkout" git commit -m "docs: update tool descriptions" ``` ### Testing Always test locally before pushing: ```bash theme={null} lua test # Test tools lua chat # Test conversations (sandbox mode) lua push # Then push ``` Test pushed versions in sandbox before deploying: ```bash theme={null} lua push lua chat # Choose sandbox mode # If good, then deploy lua deploy ``` Keep a list of test scenarios: * Happy path flows * Edge cases to verify * Known issues to watch for ### Deployment **Pre-Deployment Checklist:** * ✅ Synced with server: `lua sync` * ✅ Tested all tools with `lua test` * ✅ Tested conversational flows with `lua chat` * ✅ Updated version number * ✅ Updated tool descriptions * ✅ Checked error messages * ✅ Tested in sandbox mode * ✅ Verified environment variables ## Common Workflows ### Quick Fix Workflow ```bash theme={null} # 1. Make the fix vim src/tools/MyTool.ts # 2. Test locally lua test # 3. Push and deploy lua push lua deploy ``` ### Feature Development Workflow ```bash theme={null} # 1. Create feature branch git checkout -b feature/new-tool # 2. Develop and test lua test # Test tool logic lua chat # Test in conversation # 3. Test thoroughly lua test # All tools lua chat # All scenarios # 4. Commit changes git add . git commit -m "feat: add new tool" # 5. Push to server lua push # 6. Merge to main git checkout main git merge feature/new-tool # 7. Deploy to production lua deploy ``` ### Multi-Developer Workflow ```bash theme={null} # Developer 1: Working on Skill A cd skill-a lua sync # Get any changes from teammates lua test lua chat # Independent sandbox # Developer 2: Working on Skill B cd skill-b lua sync # Get any changes from teammates lua test lua chat # Independent sandbox # Each developer has independent sandbox # Sync helps prevent conflicts when deploying ``` ## Troubleshooting **Problem**: Cannot start chat session **Solutions**: 1. Run `lua auth configure` to set up API key 2. Ensure `lua.skill.yaml` exists (run `lua init`) 3. Deploy skills with `lua push` before using sandbox 4. Check network connection **Problem**: Local changes not reflected **Solutions**: 1. Ensure you selected "Sandbox" mode 2. Check compilation succeeded 3. Verify skills pushed to sandbox successfully 4. Try running `lua push` first **Problem**: Deployed but still seeing old behavior **Solutions**: 1. Verify correct version was deployed 2. Check `lua.skill.yaml` version number 3. Test in production mode with `lua chat` 4. Check if deploy actually succeeded **Problem**: Version already pushed to server **Solution**: Increment version number: ```yaml theme={null} skills: - name: my-skill version: 1.0.1 # Increment this ``` **Problem**: Server has different persona/name than local code **Solutions**: 1. Run `lua sync` to see the diff and choose action 2. Use `lua sync --accept` to auto-update from server 3. Use `lua sync --push` to push local changes to server **Note**: By default, `lua compile` does NOT check for drift. Use `lua compile --sync` to enable drift detection. This happens when someone updated the agent from the admin dashboard. ## Next Steps Complete workflow for AI IDEs (Cursor, Windsurf, Copilot) Complete command documentation Follow a complete tutorial Automate commands for CI/CD and scripting Atomic agent versions, promote, rollback, and version status # Customer Support Agent Source: https://docs.heylua.ai/demos/customer-support Support automation with Zendesk API + Lua vector search ## Overview AI-powered customer support that integrates with **Zendesk** for ticketing and **Lua Data API** for knowledge base search. **What it does:** * Search knowledge base with semantic search * Create support tickets in Zendesk * Check ticket status * Answer common questions * Escalate to human agents **APIs used:** Zendesk API (external) + Lua Data API (vector search) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaWebhook, LuaJob } from "lua-cli"; import { SearchKnowledgeBaseTool, CreateTicketTool, GetTicketStatusTool, UpdateTicketTool } from "./tools/SupportTools"; // Support skill const supportSkill = new LuaSkill({ name: "customer-support", description: "AI-powered customer support with ticketing and knowledge base", context: ` This skill provides customer support. - search_knowledge_base: Use first to find answers in documentation - create_ticket: Create Zendesk ticket if knowledge base can't help - get_ticket_status: Check status of existing tickets - update_ticket: Add information to existing tickets Always search knowledge base before creating tickets. Be empathetic and professional. Escalate complex issues to human agents. `, tools: [ new SearchKnowledgeBaseTool(), new CreateTicketTool(), new GetTicketStatusTool(), new UpdateTicketTool() ] }); // Zendesk webhook for ticket updates const zendeskWebhook = new LuaWebhook({ name: 'zendesk-webhook', description: 'Handle Zendesk ticket update events', secret: env('ZENDESK_WEBHOOK_SECRET'), execute: async (event) => { if (event.type === 'ticket.solved') { // Get user ID from ticket metadata const userId = event.data.custom_fields?.lua_user_id; if (userId) { const user = await User.get(userId); await user.send([{ type: 'text', text: `✅ Your support ticket #${event.data.id} has been resolved! We hope we were able to help.` }]); } } return { received: true }; } }); // Daily follow-up job for open tickets const ticketFollowUpJob = new LuaJob({ name: 'ticket-followup', description: 'Send follow-up messages for open tickets', schedule: { type: 'cron', pattern: '0 10 * * *' // Daily at 10 AM }, execute: async (job) => { const tickets = await Data.search('support_tickets', 'status:open', 50); const user = await job.user(); if (tickets.length > 0) { await user.send([{ type: 'text', text: `You have ${tickets.length} open support ticket(s). Need any updates?` }]); } } }); // Configure agent export const agent = new LuaAgent({ name: "support-agent", persona: `You are Alex, a customer support specialist. Your role: - Help customers find answers to their questions - Search the knowledge base for solutions - Create support tickets when needed - Track and update existing tickets - Provide empathetic and professional support Communication style: - Patient and empathetic - Clear and professional - Solution-oriented - Reassuring and supportive Workflow: 1. First, search the knowledge base for answers 2. If no solution found, offer to create a ticket 3. For existing tickets, help track status 4. For urgent issues, escalate to priority support Best practices: - Always search knowledge base first - Gather all details before creating tickets - Set realistic expectations for response times - Thank customers for their patience - Confirm resolution before closing When to escalate: - Billing disputes over $500 - Account security issues - Legal or compliance matters - VIP customer requests`, skills: [supportSkill], webhooks: [zendeskWebhook], jobs: [ticketFollowUpJob] }); ``` This demo uses `LuaAgent` with webhooks for Zendesk events and scheduled jobs for ticket follow-ups. ### src/tools/SupportTools.ts ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Search Knowledge Base (Lua Data with Vector Search) export class SearchKnowledgeBaseTool implements LuaTool { name = "search_knowledge_base"; description = "Search help articles and documentation"; inputSchema = z.object({ query: z.string().describe("User's question or search query") }); async execute(input: z.infer) { // Use Lua's vector search for semantic matching const results = await Data.search( 'help_articles', input.query, 5, 0.7 ); if (results.length === 0) { return { articles: [], message: "No articles found. Would you like to create a support ticket?" }; } return { articles: results.map(entry => ({ id: entry.id, title: entry.title, content: entry.content.substring(0, 300) + '...', category: entry.category, relevance: `${Math.round(entry.score * 100)}% match`, url: entry.url })), count: results.length, message: `Found ${results.length} helpful articles` }; } } // 2. Create Zendesk Ticket (External API) export class CreateTicketTool implements LuaTool { name = "create_ticket"; description = "Create a support ticket in Zendesk"; inputSchema = z.object({ subject: z.string().describe("Ticket subject/summary"), description: z.string().describe("Detailed description of the issue"), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'), customerEmail: z.string().email().describe("Customer's email address"), customerName: z.string().describe("Customer's name") }); async execute(input: z.infer) { const zendeskKey = env('ZENDESK_API_KEY'); const zendeskSubdomain = env('ZENDESK_SUBDOMAIN'); if (!zendeskKey || !zendeskSubdomain) { throw new Error('Zendesk API credentials not configured'); } // Create ticket in Zendesk const response = await fetch( `https://${zendeskSubdomain}.zendesk.com/api/v2/tickets.json`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`${input.customerEmail}/token:${zendeskKey}`).toString('base64')}` }, body: JSON.stringify({ ticket: { subject: input.subject, description: input.description, priority: input.priority, requester: { name: input.customerName, email: input.customerEmail }, tags: ['ai_created', 'chat'] } }) } ); if (!response.ok) { throw new Error(`Failed to create ticket: ${response.statusText}`); } const data = await response.json(); // Also log ticket in Lua Data for our records await Data.create('support_tickets', { zendeskId: data.ticket.id, subject: input.subject, customerEmail: input.customerEmail, priority: input.priority, createdAt: new Date().toISOString() }, `${input.subject} ${input.description}`); return { success: true, ticketId: data.ticket.id, ticketUrl: data.ticket.url, message: `Ticket #${data.ticket.id} created. We'll respond within 24 hours.` }; } } // 3. Get Ticket Status (External API) export class GetTicketStatusTool implements LuaTool { name = "get_ticket_status"; description = "Check the status of a support ticket"; inputSchema = z.object({ ticketId: z.string().describe("Ticket ID") }); async execute(input: z.infer) { const zendeskKey = env('ZENDESK_API_KEY'); const zendeskSubdomain = env('ZENDESK_SUBDOMAIN'); // Get ticket from Zendesk const response = await fetch( `https://${zendeskSubdomain}.zendesk.com/api/v2/tickets/${input.ticketId}.json`, { headers: { 'Authorization': `Basic ${Buffer.from(`${env('ZENDESK_EMAIL')}/token:${zendeskKey}`).toString('base64')}` } } ); if (!response.ok) { throw new Error(`Ticket not found: ${input.ticketId}`); } const data = await response.json(); const ticket = data.ticket; return { ticketId: ticket.id, subject: ticket.subject, status: ticket.status, priority: ticket.priority, createdAt: new Date(ticket.created_at).toLocaleDateString(), updatedAt: new Date(ticket.updated_at).toLocaleDateString(), assignee: ticket.assignee_id ? 'Assigned to support agent' : 'Not yet assigned', message: this.getStatusMessage(ticket.status) }; } private getStatusMessage(status: string): string { const messages = { new: "Your ticket is in queue and will be reviewed shortly", open: "Our team is currently working on your ticket", pending: "Waiting for your response", solved: "This ticket has been resolved", closed: "This ticket is closed" }; return messages[status] || "Status unknown"; } } // 4. Update Ticket (External API) export class UpdateTicketTool implements LuaTool { name = "update_ticket"; description = "Add a comment or update to an existing ticket"; inputSchema = z.object({ ticketId: z.string(), comment: z.string().describe("Additional information or update") }); async execute(input: z.infer) { const zendeskKey = env('ZENDESK_API_KEY'); const zendeskSubdomain = env('ZENDESK_SUBDOMAIN'); // Add comment to ticket const response = await fetch( `https://${zendeskSubdomain}.zendesk.com/api/v2/tickets/${input.ticketId}.json`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`${env('ZENDESK_EMAIL')}/token:${zendeskKey}`).toString('base64')}` }, body: JSON.stringify({ ticket: { comment: { body: input.comment, public: true, author_id: 'end-user' } } }) } ); if (!response.ok) { throw new Error('Failed to update ticket'); } return { success: true, ticketId: input.ticketId, message: "Your comment has been added to the ticket" }; } } ``` ## Environment Setup ```bash theme={null} # .env ZENDESK_API_KEY=your_zendesk_api_key ZENDESK_SUBDOMAIN=your_company ZENDESK_EMAIL=support@yourcompany.com ``` ## Seed Knowledge Base ```typescript theme={null} // scripts/seed-knowledge-base.ts import { Data } from "lua-cli"; const articles = [ { title: "How to reset your password", content: "To reset your password: 1) Click 'Forgot Password' 2) Enter your email 3) Check your inbox for reset link 4) Create new password", category: "Account", url: "/help/reset-password" }, { title: "Shipping and delivery times", content: "Standard shipping takes 5-7 business days. Express shipping takes 2-3 business days. Free shipping on orders over $50.", category: "Shipping", url: "/help/shipping" }, { title: "Return policy", content: "Returns accepted within 30 days of purchase. Items must be unused with original packaging. Refunds processed within 5-10 business days.", category: "Returns", url: "/help/returns" } ]; async function seedKnowledgeBase() { for (const article of articles) { const searchText = `${article.title} ${article.content} ${article.category}`; await Data.create('help_articles', article, searchText); } console.log('✅ Knowledge base seeded'); } seedKnowledgeBase(); ``` ## Key Features Integrates with Zendesk ticketing AI-powered knowledge base search Best of both worlds Search first, create ticket only if needed ## Next Demo See reservation management with Lua Data API # E-commerce Shopping Assistant Source: https://docs.heylua.ai/demos/ecommerce-assistant Complete shopping experience using Lua Platform APIs ## Overview A fully-featured e-commerce shopping assistant using **Lua Platform APIs** for products, shopping cart, and order management. **What it does:** * Search and browse products * Add items to cart * Manage shopping basket * Complete checkout * Track orders **APIs used:** Lua Platform APIs (Products, Baskets, Orders) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { SearchProductsTool, BrowseProductsTool, GetProductDetailsTool, AddToCartTool, ViewCartTool, RemoveFromCartTool, CheckoutTool, TrackOrderTool } from "./tools/EcommerceTool"; // Create shopping skill const ecommerceSkill = new LuaSkill({ name: "ecommerce-assistant", description: "AI shopping assistant for e-commerce websites", context: ` This skill helps customers shop and complete purchases. Shopping Flow: - search_products: When users describe what they want to buy (semantic search) - browse_products: When users want to filter by category, price range, or availability - get_product_details: When they want more info about a specific product - add_to_cart: When they decide to purchase something - view_cart: To review their shopping cart - remove_from_cart: To remove unwanted items - checkout: To complete the purchase - track_order: To check order status Guidelines: - Use search_products for natural language queries like "laptop for students" - Use browse_products for structured queries like "electronics under $500" - Always confirm items and quantities before adding to cart - Show total price before checkout - Ask for shipping address during checkout - Be helpful with product recommendations `, tools: [ new SearchProductsTool(), new BrowseProductsTool(), new GetProductDetailsTool(), new AddToCartTool(), new ViewCartTool(), new RemoveFromCartTool(), new CheckoutTool(), new TrackOrderTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "ecommerce-shopping-assistant", persona: `You are a friendly and helpful shopping assistant for our online store. Your role: - Help customers find products they're looking for - Provide detailed product information - Assist with adding items to cart - Guide through the checkout process - Help track orders after purchase Communication style: - Warm and welcoming - Enthusiastic about products - Patient and helpful - Clear about pricing and availability - Proactive with suggestions Best practices: - Always confirm product details before adding to cart - Mention if items are in stock or out of stock - Show total price before checkout - Offer product recommendations based on browsing - Celebrate successful orders! When to escalate: - Complex shipping issues - Payment problems - Bulk orders (>20 items) - Special customization requests`, skills: [ecommerceSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills in one unified structure. ### src/tools/EcommerceTool.ts ```typescript theme={null} import { LuaTool, Products, Baskets, Orders } from "lua-cli"; import { z } from "zod"; // 1. Search Products export class SearchProductsTool implements LuaTool { name = "search_products"; description = "Search for products by name, category, or description"; inputSchema = z.object({ query: z.string().describe("Search query (e.g., 'laptop', 'running shoes')"), maxPrice: z.number().optional().describe("Maximum price filter") }); async execute(input: z.infer) { const results = await Products.search(input.query); // Filter by max price if specified let products = results.products; if (input.maxPrice) { products = products.filter(p => p.price <= input.maxPrice); } return { products: products.slice(0, 10).map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, category: p.category, inStock: p.inStock, description: p.description?.substring(0, 100) + '...' })), total: products.length, showing: Math.min(products.length, 10) }; } } // 2. Browse Products by Category/Price (Filter-based) export class BrowseProductsTool implements LuaTool { name = "browse_products"; description = "Browse products by category, price range, or availability"; inputSchema = z.object({ category: z.string().optional().describe("Product category"), minPrice: z.number().optional().describe("Minimum price"), maxPrice: z.number().optional().describe("Maximum price"), inStockOnly: z.boolean().optional().describe("Only show in-stock items"), page: z.number().optional().default(1) }); async execute(input: z.infer) { const filter: Record = {}; if (input.category) filter.category = input.category; if (input.minPrice !== undefined || input.maxPrice !== undefined) { filter.price = {}; if (input.minPrice !== undefined) filter.price.$gte = input.minPrice; if (input.maxPrice !== undefined) filter.price.$lte = input.maxPrice; } if (input.inStockOnly) filter.inStock = true; const results = await Products.get({ page: input.page, limit: 10, filter }); return { products: results.map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, category: p.category, inStock: p.inStock ? '✅ In Stock' : '❌ Out of Stock' })), pagination: results.pagination }; } } // 3. Get Product Details export class GetProductDetailsTool implements LuaTool { name = "get_product_details"; description = "Get detailed information about a specific product"; inputSchema = z.object({ productId: z.string().describe("Product ID") }); async execute(input: z.infer) { const product = await Products.getById(input.productId); if (!product) { throw new Error(`Product not found: ${input.productId}`); } return { id: product.id, name: product.name, price: `$${product.price.toFixed(2)}`, description: product.description, category: product.category, sku: product.sku, inStock: product.inStock, availability: product.inStock ? "✅ In stock - Ships within 24 hours" : "❌ Out of stock - Notify when available?" }; } } // 4. Add to Cart export class AddToCartTool implements LuaTool { name = "add_to_cart"; description = "Add a product to the shopping cart"; inputSchema = z.object({ productId: z.string().describe("Product ID to add"), quantity: z.number().min(1).default(1).describe("Quantity to add"), basketId: z.string().optional().describe("Existing basket ID (creates new if not provided)") }); async execute(input: z.infer) { // Get product details const product = await Products.getById(input.productId); if (!product) { throw new Error(`Product not found: ${input.productId}`); } if (!product.inStock) { return { success: false, message: `Sorry, ${product.name} is currently out of stock` }; } // Get or create basket let basket; if (input.basketId) { basket = await Baskets.getById(input.basketId); } else { basket = await Baskets.create({ currency: 'USD', metadata: { source: 'ai_chat' } }); } // Add item to basket const updated = await Baskets.addItem(basket.id, { id: input.productId, price: product.price, quantity: input.quantity, SKU: product.sku }); return { success: true, basketId: updated.id, itemCount: updated.common.itemCount, subtotal: `$${updated.common.totalAmount.toFixed(2)}`, message: `Added ${input.quantity}x ${product.name} to your cart` }; } } // 5. View Cart export class ViewCartTool implements LuaTool { name = "view_cart"; description = "View items in the shopping cart"; inputSchema = z.object({ basketId: z.string().describe("Basket ID") }); async execute(input: z.infer) { const basket = await Baskets.getById(input.basketId); if (!basket) { throw new Error("Cart not found"); } return { items: basket.common.items.map(item => ({ productId: item.id, quantity: item.quantity, price: `$${item.price.toFixed(2)}`, subtotal: `$${(item.price * item.quantity).toFixed(2)}`, sku: item.SKU })), itemCount: basket.common.itemCount, total: `$${basket.common.totalAmount.toFixed(2)}`, basketId: basket.id }; } } // 6. Remove from Cart export class RemoveFromCartTool implements LuaTool { name = "remove_from_cart"; description = "Remove an item from the shopping cart"; inputSchema = z.object({ basketId: z.string(), itemId: z.string().describe("Item/Product ID to remove") }); async execute(input: z.infer) { await Baskets.removeItem(input.basketId, input.itemId); const updated = await Baskets.getById(input.basketId); return { success: true, itemCount: updated.common.itemCount, total: `$${updated.common.totalAmount.toFixed(2)}`, message: "Item removed from cart" }; } } // 7. Checkout export class CheckoutTool implements LuaTool { name = "checkout"; description = "Complete purchase and create order"; inputSchema = z.object({ basketId: z.string(), shippingAddress: z.object({ name: z.string(), street: z.string(), city: z.string(), state: z.string(), zip: z.string(), country: z.string().default('USA') }), email: z.string().email(), paymentMethod: z.string().default('stripe') }); async execute(input: z.infer) { const basket = await Baskets.getById(input.basketId); if (basket.common.itemCount === 0) { return { success: false, message: "Cannot checkout with empty cart" }; } // Create order const order = await Baskets.placeOrder({ shippingAddress: input.shippingAddress, paymentMethod: input.paymentMethod, customerEmail: input.email }, input.basketId); return { success: true, orderId: order.id, total: `$${basket.common.totalAmount.toFixed(2)}`, itemCount: basket.common.itemCount, estimatedDelivery: this.calculateDeliveryDate(), message: `Order confirmed! You'll receive a confirmation email at ${input.email}` }; } private calculateDeliveryDate(): string { const date = new Date(); date.setDate(date.getDate() + 5); // 5 business days return date.toLocaleDateString(); } } // 8. Track Order export class TrackOrderTool implements LuaTool { name = "track_order"; description = "Get order status and tracking information"; inputSchema = z.object({ orderId: z.string().describe("Order ID to track") }); async execute(input: z.infer) { const order = await Orders.getById(input.orderId); if (!order) { throw new Error(`Order not found: ${input.orderId}`); } const statusMessages = { pending: "Your order is being processed", confirmed: "Order confirmed and being prepared for shipping", fulfilled: "Order delivered!", cancelled: "Order was cancelled" }; return { orderId: order.id, status: order.common.status, statusMessage: statusMessages[order.common.status] || "Unknown status", total: `$${order.common.totalAmount.toFixed(2)}`, itemCount: order.common.itemCount, estimatedDelivery: order.data?.estimatedDelivery, trackingNumber: order.data?.trackingNumber }; } } ``` ## Environment Setup ```bash theme={null} # .env (not needed - uses Platform APIs) # No external API keys required ``` ## Testing ```bash theme={null} # Test individual tools lua test # Test conversation flow lua chat ``` **Test conversation flow in sandbox mode:** 1. "Search for laptops" (semantic search) 2. "Show me electronics under \$500" (filter-based browsing) 3. "Add the MacBook to my cart" 4. "Show me my cart" 5. "Checkout with shipping to 123 Main St, New York, NY 10001" 6. "Track my order" ## Deployment ```bash theme={null} lua push lua deploy ``` ## Key Features Uses Lua's built-in e-commerce APIs Search → Add → Checkout → Track Everything built-in Full error handling and validation ## Customization ### Add Recommendations ```typescript theme={null} async execute(input) { const product = await Products.getById(input.productId); // Find similar products const similar = await Products.search(product.category); const recommendations = similar.data .filter(p => p.id !== product.id) .slice(0, 3); return { product, recommendations }; } ``` ### Add Discount Codes ```typescript theme={null} inputSchema = z.object({ basketId: z.string(), discountCode: z.string().optional() }); async execute(input) { const basket = await Baskets.getById(input.basketId); if (input.discountCode) { const discount = await validateDiscountCode(input.discountCode); await Baskets.updateMetadata(input.basketId, { discountCode: input.discountCode, discountAmount: basket.common.totalAmount * discount.percentage }); } } ``` ## Next Demo See external API integration with Zendesk # Finance Operations Agent Source: https://docs.heylua.ai/demos/finance-operations Banking operations with Plaid API ## Overview Internal finance operations assistant using **Plaid API** for banking and account management. **What it does:** * Check account balances * View recent transactions * Transfer funds between accounts * Generate financial reports * Verify payments **APIs used:** Plaid API (external banking) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaJob } from "lua-cli"; import { GetBalanceTool, GetTransactionsTool, TransferFundsTool } from "./tools/FinanceTools"; // Finance operations skill const financeSkill = new LuaSkill({ name: "finance-operations", description: "Internal finance and banking operations", context: ` This skill provides finance team with banking operations. - get_balance: Check account balances - get_transactions: View transaction history - transfer_funds: Move money between accounts Always verify amounts and account numbers. Follow approval workflows for large transfers. `, tools: [ new GetBalanceTool(), new GetTransactionsTool(), new TransferFundsTool() ] }); // Weekly reconciliation job const weeklyReconciliationJob = new LuaJob({ name: 'weekly-reconciliation', description: 'Send weekly account reconciliation summary', schedule: { type: 'cron', pattern: '0 9 * * 1' // Every Monday at 9 AM }, execute: async (job) => { const user = await job.user(); await user.send([{ type: 'text', text: '📊 Weekly reconciliation report is ready for review.' }]); } }); // Configure agent export const agent = new LuaAgent({ name: "finance-operations-assistant", persona: `You are a finance operations specialist assistant. Your role: - Help finance team check account balances - Provide transaction histories - Process fund transfers - Generate financial reports - Monitor account activities Communication style: - Professional and precise - Detail-oriented - Security-conscious - Clear with numbers - Compliant with regulations Best practices: - Always verify account numbers before transfers - Double-check transfer amounts - Provide clear transaction summaries - Flag unusual activity - Follow approval workflows for large amounts Security requirements: - Verify user authorization - Log all transfer requests - Require approval for transfers >$10,000 - Maintain audit trail - Follow SOX compliance When to escalate: - Transfers over $10,000 - Suspicious transactions - Account discrepancies - External audit requests`, skills: [financeSkill], jobs: [weeklyReconciliationJob] }); ``` This demo uses `LuaAgent` with scheduled jobs for weekly reconciliation reports. ### src/tools/FinanceTools.ts ```typescript theme={null} import { LuaTool, env } from "lua-cli"; import { z } from "zod"; // 1. Get Account Balance export class GetBalanceTool implements LuaTool { name = "get_balance"; description = "Check account balance"; inputSchema = z.object({ accountId: z.string().describe("Account ID") }); async execute(input: z.infer) { const plaidKey = env('PLAID_CLIENT_ID'); const plaidSecret = env('PLAID_SECRET'); const response = await fetch('https://production.plaid.com/accounts/balance/get', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: plaidKey, secret: plaidSecret, access_token: env('PLAID_ACCESS_TOKEN'), options: { account_ids: [input.accountId] } }) }); const data = await response.json(); const account = data.accounts[0]; return { accountId: account.account_id, accountName: account.name, currentBalance: `$${account.balances.current.toLocaleString()}`, availableBalance: `$${account.balances.available.toLocaleString()}`, currency: account.balances.iso_currency_code, lastUpdated: new Date().toISOString() }; } } // 2. Get Transactions export class GetTransactionsTool implements LuaTool { name = "get_transactions"; description = "View recent transactions"; inputSchema = z.object({ accountId: z.string(), days: z.number().min(1).max(90).default(30) }); async execute(input: z.infer) { const plaidKey = env('PLAID_CLIENT_ID'); const plaidSecret = env('PLAID_SECRET'); const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - input.days); const response = await fetch('https://production.plaid.com/transactions/get', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: plaidKey, secret: plaidSecret, access_token: env('PLAID_ACCESS_TOKEN'), start_date: startDate.toISOString().split('T')[0], end_date: endDate.toISOString().split('T')[0], options: { account_ids: [input.accountId] } }) }); const data = await response.json(); return { transactions: data.transactions.map(t => ({ date: t.date, name: t.name, amount: `$${Math.abs(t.amount).toFixed(2)}`, type: t.amount < 0 ? 'debit' : 'credit', category: t.category[0], pending: t.pending })), total: data.transactions.length }; } } // 3. Transfer Funds export class TransferFundsTool implements LuaTool { name = "transfer_funds"; description = "Transfer money between accounts"; inputSchema = z.object({ fromAccountId: z.string(), toAccountId: z.string(), amount: z.number().positive(), description: z.string().optional() }); async execute(input: z.infer) { // Validate amount if (input.amount > 10000) { throw new Error('Transfers over $10,000 require additional approval'); } const plaidKey = env('PLAID_CLIENT_ID'); const plaidSecret = env('PLAID_SECRET'); const response = await fetch('https://production.plaid.com/transfer/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: plaidKey, secret: plaidSecret, access_token: env('PLAID_ACCESS_TOKEN'), account_id: input.fromAccountId, type: 'debit', network: 'ach', amount: input.amount.toString(), description: input.description || 'Transfer', user: { legal_name: 'Company Name' } }) }); const data = await response.json(); return { success: true, transferId: data.transfer.id, amount: `$${input.amount.toFixed(2)}`, status: data.transfer.status, message: `Transfer of $${input.amount.toFixed(2)} initiated successfully` }; } } ``` ## Environment Setup ```bash theme={null} # .env PLAID_CLIENT_ID=your_plaid_client_id PLAID_SECRET=your_plaid_secret PLAID_ACCESS_TOKEN=your_access_token PLAID_ENV=production ``` ## Key Features Plaid API integration Bank-grade security Live account data Financial regulations # Financial Services Onboarding Source: https://docs.heylua.ai/demos/financial-onboarding KYC onboarding with document verification and compliance checks ## Overview Complete financial services onboarding agent with **KYC (Know Your Customer)** verification using **Stripe Identity API** for document verification and **Lua Data API** for application management. **What it does:** * Guide users through onboarding journey * Collect personal and financial information * Upload and verify identity documents (ID, passport) * Answer qualifying questions * Perform compliance checks * Create verified account **APIs used:** Stripe Identity API (document verification) + Lua Data API (application tracking) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaWebhook, PreProcessor, PostProcessor } from "lua-cli"; import { StartOnboardingTool, CollectPersonalInfoTool, UploadDocumentTool, VerifyIdentityTool, AnswerQualifyingQuestionsTool, CreateAccountTool, CheckOnboardingStatusTool } from "./tools/FinancialOnboardingTools"; // Onboarding skill const financialOnboardingSkill = new LuaSkill({ name: "financial-onboarding", description: "Financial services customer onboarding with KYC verification", context: ` This skill guides customers through financial account onboarding. Onboarding Flow (in order): 1. start_onboarding: Begin new application 2. collect_personal_info: Gather basic information 3. upload_document: Upload ID, passport, or proof of address 4. verify_identity: Verify uploaded documents 5. answer_qualifying_questions: Financial suitability assessment 6. create_account: Complete account creation 7. check_onboarding_status: Check application status Guidelines: - Be professional and reassuring about data security - Explain why each document is needed (regulatory compliance) - Never rush through identity verification steps - Clearly communicate what happens to uploaded documents - Follow KYC and AML regulations - Ensure GDPR/CCPA compliance `, tools: [ new StartOnboardingTool(), new CollectPersonalInfoTool(), new UploadDocumentTool(), new VerifyIdentityTool(), new AnswerQualifyingQuestionsTool(), new CreateAccountTool(), new CheckOnboardingStatusTool() ] }); // Stripe Identity webhook for verification results const stripeIdentityWebhook = new LuaWebhook({ name: 'stripe-identity-webhook', description: 'Handle Stripe Identity verification events', secret: env('STRIPE_WEBHOOK_SECRET'), execute: async (event) => { if (event.type === 'identity.verification_session.verified') { const user = await User.get(); await user.send([{ type: 'text', text: '✅ Identity verification successful! Proceeding with account creation...' }]); } return { received: true }; } }); // Information validation preprocessor const validateInformationPreProcessor = new PreProcessor({ name: 'validate-financial-info', description: 'Ensure required information is provided', execute: async (message, user) => { // Ensure user has started onboarding const applications = await Data.search('onboarding_applications', user.email, 1); if (applications.length === 0) { return { block: true, response: "Please start the onboarding process first by providing your email address." }; } return { block: false }; } }); // Compliance disclaimer postprocessor const complianceDisclaimerPostProcessor = new PostProcessor({ name: 'compliance-disclaimer', description: 'Add regulatory disclaimers to responses', execute: async (user, message, response, channel) => { return { modifiedResponse: response + "\n\n_Banking services provided by our partner bank. FDIC insured. Member FDIC. Your information is encrypted and secure._" }; } }); // Configure agent export const agent = new LuaAgent({ name: "financial-onboarding-agent", persona: `You are a professional financial services onboarding specialist. Your role: - Guide customers through account opening process - Collect required KYC information - Verify identity documents - Assess financial suitability - Ensure regulatory compliance Communication style: - Professional and trustworthy - Clear and reassuring - Patient and thorough - Transparent about data security - Compliant with regulations Compliance requirements: - Follow KYC (Know Your Customer) procedures - Adhere to AML (Anti-Money Laundering) regulations - Ensure GDPR/CCPA compliance - Verify identity before account creation - Document all customer interactions Best practices: - Explain why each document is needed - Reassure customers about data security - Never rush through verification steps - Clearly communicate processing times - Provide next steps at each stage Security reminders: - All information is encrypted - Documents are securely stored - Compliance with banking regulations - Data is never shared without consent`, skills: [financialOnboardingSkill], webhooks: [stripeIdentityWebhook], preProcessors: [validateInformationPreProcessor], postProcessors: [complianceDisclaimerPostProcessor] }); ``` This demo uses `LuaAgent` with webhooks for Stripe Identity events, preprocessors for validation, and postprocessors for compliance disclaimers. ### src/tools/FinancialOnboardingTools.ts ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Start Onboarding export class StartOnboardingTool implements LuaTool { name = "start_onboarding"; description = "Begin a new account onboarding application"; inputSchema = z.object({ email: z.string().email().describe("Applicant's email address"), accountType: z.enum(['individual', 'business']).describe("Type of account") }); async execute(input: z.infer) { // Create onboarding application const application = await Data.create('onboarding_applications', { email: input.email, accountType: input.accountType, status: 'started', currentStep: 'personal_info', createdAt: new Date().toISOString(), completedSteps: [] }, input.email); return { applicationId: application.id, accountType: input.accountType, nextStep: 'personal_info', message: "Application started! Let's begin by collecting your personal information.", estimatedTime: "5-10 minutes to complete" }; } } // 2. Collect Personal Information export class CollectPersonalInfoTool implements LuaTool { name = "collect_personal_info"; description = "Collect applicant's personal information"; inputSchema = z.object({ applicationId: z.string(), personalInfo: z.object({ firstName: z.string(), lastName: z.string(), dateOfBirth: z.string().describe("YYYY-MM-DD"), ssn: z.string().describe("Social Security Number (will be encrypted)"), phone: z.string(), address: z.object({ street: z.string(), city: z.string(), state: z.string(), zipCode: z.string(), country: z.string().default('USA') }) }) }); async execute(input: z.infer) { // Get application const app = await Data.getEntry('onboarding_applications', input.applicationId); if (!app) { throw new Error('Application not found'); } // Encrypt SSN before storing (in production, use proper encryption) const encryptedSSN = this.encryptSSN(input.personalInfo.ssn); // Update application with personal info await Data.update('onboarding_applications', input.applicationId, { ...app.data, personalInfo: { ...input.personalInfo, ssn: encryptedSSN // Store encrypted }, currentStep: 'document_upload', completedSteps: [...app.data.completedSteps, 'personal_info'], updatedAt: new Date().toISOString() }); return { success: true, nextStep: 'document_upload', message: "Personal information saved securely. Next, please upload a government-issued ID.", documentsNeeded: [ "Government-issued photo ID (driver's license or passport)", "Proof of address (utility bill or bank statement)" ] }; } private encryptSSN(ssn: string): string { // In production, use proper encryption (AES-256, KMS, etc.) // This is a placeholder return Buffer.from(ssn).toString('base64'); } } // 3. Upload Document (Stripe Identity API) export class UploadDocumentTool implements LuaTool { name = "upload_document"; description = "Upload identity verification document"; inputSchema = z.object({ applicationId: z.string(), documentType: z.enum(['drivers_license', 'passport', 'id_card', 'proof_of_address']), documentImageUrl: z.string().url().describe("URL of uploaded document image"), documentSide: z.enum(['front', 'back']).optional().describe("For driver's license") }); async execute(input: z.infer) { const stripeKey = env('STRIPE_SECRET_KEY'); if (!stripeKey) { throw new Error('Stripe API key not configured'); } // Create Stripe Identity Verification Session const verificationResponse = await fetch('https://api.stripe.com/v1/identity/verification_sessions', { method: 'POST', headers: { 'Authorization': `Bearer ${stripeKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ 'type': 'document', 'metadata[application_id]': input.applicationId, 'metadata[document_type]': input.documentType }) }); if (!verificationResponse.ok) { throw new Error('Failed to create verification session'); } const verification = await verificationResponse.json(); // Upload document to Stripe const uploadResponse = await fetch('https://files.stripe.com/v1/files', { method: 'POST', headers: { 'Authorization': `Bearer ${stripeKey}` }, body: this.createFormData(input.documentImageUrl, input.documentType) }); const uploadedFile = await uploadResponse.json(); // Save document reference in application const app = await Data.getEntry('onboarding_applications', input.applicationId); const documents = app.data.documents || []; documents.push({ type: input.documentType, side: input.documentSide, stripeFileId: uploadedFile.id, stripeVerificationId: verification.id, uploadedAt: new Date().toISOString(), status: 'pending_verification' }); await Data.update('onboarding_applications', input.applicationId, { ...app.data, documents, currentStep: 'identity_verification', updatedAt: new Date().toISOString() }); return { success: true, documentId: uploadedFile.id, verificationId: verification.id, status: 'uploaded', message: "Document uploaded successfully. Verification in progress...", nextStep: "We'll verify your identity. This usually takes 1-2 minutes.", verificationUrl: verification.url // User can complete verification here }; } private createFormData(imageUrl: string, documentType: string): FormData { const formData = new FormData(); formData.append('purpose', 'identity_document'); formData.append('file', imageUrl); return formData; } } // 4. Verify Identity (Check Stripe Identity Results) export class VerifyIdentityTool implements LuaTool { name = "verify_identity"; description = "Check identity verification status"; inputSchema = z.object({ applicationId: z.string() }); async execute(input: z.infer) { const stripeKey = env('STRIPE_SECRET_KEY'); const app = await Data.getEntry('onboarding_applications', input.applicationId); if (!app.data.documents || app.data.documents.length === 0) { return { verified: false, message: "No documents uploaded yet. Please upload your ID first." }; } // Check verification status with Stripe const latestDoc = app.data.documents[app.data.documents.length - 1]; const response = await fetch( `https://api.stripe.com/v1/identity/verification_sessions/${latestDoc.stripeVerificationId}`, { headers: { 'Authorization': `Bearer ${stripeKey}` } } ); const verification = await response.json(); const isVerified = verification.status === 'verified'; // Update application if (isVerified) { await Data.update('onboarding_applications', input.applicationId, { ...app.data, identityVerified: true, verificationResult: { verified: true, verifiedAt: new Date().toISOString(), documentType: verification.last_verification_report?.document?.type, nameMatch: verification.last_verification_report?.id_number?.status === 'verified' }, currentStep: 'qualifying_questions', completedSteps: [...app.data.completedSteps, 'identity_verification'] }); } return { verified: isVerified, status: verification.status, message: isVerified ? "✅ Identity verified successfully! Let's continue with some qualifying questions." : verification.status === 'processing' ? "⏳ Verification in progress. Please wait..." : "❌ Verification failed. Please upload a clearer image of your ID.", nextStep: isVerified ? 'qualifying_questions' : 'document_upload', verificationDetails: isVerified ? { documentType: verification.last_verification_report?.document?.type, issueDate: verification.last_verification_report?.document?.issued_date, expirationDate: verification.last_verification_report?.document?.expiration_date } : null }; } } // 5. Answer Qualifying Questions export class AnswerQualifyingQuestionsTool implements LuaTool { name = "answer_qualifying_questions"; description = "Complete financial suitability questionnaire"; inputSchema = z.object({ applicationId: z.string(), answers: z.object({ annualIncome: z.enum(['under_25k', '25k_50k', '50k_100k', '100k_250k', 'over_250k']), employmentStatus: z.enum(['employed', 'self_employed', 'unemployed', 'retired', 'student']), investmentExperience: z.enum(['none', 'limited', 'moderate', 'extensive']), riskTolerance: z.enum(['conservative', 'moderate', 'aggressive']), investmentGoals: z.array(z.enum(['retirement', 'wealth_building', 'income', 'preservation'])), investmentHorizon: z.enum(['short_term', 'medium_term', 'long_term']), liquidNetWorth: z.enum(['under_10k', '10k_50k', '50k_100k', '100k_500k', 'over_500k']), sourceOfFunds: z.enum(['employment', 'business', 'investments', 'inheritance', 'other']) }) }); async execute(input: z.infer) { const app = await Data.getEntry('onboarding_applications', input.applicationId); // Calculate suitability score const suitabilityScore = this.calculateSuitability(input.answers); // Determine if applicant qualifies const qualifies = suitabilityScore.score >= 60; // Update application await Data.update('onboarding_applications', input.applicationId, { ...app.data, qualifyingAnswers: input.answers, suitabilityScore: suitabilityScore, qualifies, currentStep: qualifies ? 'account_creation' : 'under_review', completedSteps: [...app.data.completedSteps, 'qualifying_questions'], updatedAt: new Date().toISOString() }); if (!qualifies) { return { success: false, qualifies: false, score: suitabilityScore.score, message: "Thank you for your application. Based on your responses, we need to review your application manually. Our team will contact you within 2 business days.", nextSteps: "Our compliance team will review your application" }; } return { success: true, qualifies: true, score: suitabilityScore.score, riskProfile: suitabilityScore.riskProfile, recommendedProducts: this.getRecommendedProducts(input.answers), message: "Great! You qualify for an account. Let's create your account now.", nextStep: 'account_creation' }; } private calculateSuitability(answers: any) { let score = 0; // Income scoring const incomeScores = { 'under_25k': 10, '25k_50k': 20, '50k_100k': 30, '100k_250k': 40, 'over_250k': 50 }; score += incomeScores[answers.annualIncome] || 0; // Experience scoring const experienceScores = { 'none': 5, 'limited': 15, 'moderate': 25, 'extensive': 35 }; score += experienceScores[answers.investmentExperience] || 0; // Net worth scoring const netWorthScores = { 'under_10k': 5, '10k_50k': 10, '50k_100k': 15, '100k_500k': 20, 'over_500k': 25 }; score += netWorthScores[answers.liquidNetWorth] || 0; // Determine risk profile let riskProfile = 'conservative'; if (answers.riskTolerance === 'aggressive' && answers.investmentHorizon === 'long_term') { riskProfile = 'aggressive'; } else if (answers.riskTolerance === 'moderate') { riskProfile = 'moderate'; } return { score, riskProfile, passedCompliance: score >= 60 }; } private getRecommendedProducts(answers: any) { const products = []; if (answers.investmentGoals.includes('retirement')) { products.push('IRA Account', '401(k) Rollover'); } if (answers.riskTolerance === 'conservative') { products.push('Money Market Account', 'CD Account'); } else if (answers.riskTolerance === 'aggressive') { products.push('Investment Account', 'Options Trading'); } else { products.push('Savings Account', 'Investment Account'); } return products; } } // 6. Create Account export class CreateAccountTool implements LuaTool { name = "create_account"; description = "Create verified financial services account"; inputSchema = z.object({ applicationId: z.string(), accountProducts: z.array(z.string()).describe("Selected account products"), agreeToTerms: z.boolean().describe("Must accept terms and conditions"), agreeToPrivacyPolicy: z.boolean() }); async execute(input: z.infer) { if (!input.agreeToTerms || !input.agreeToPrivacyPolicy) { throw new Error('You must agree to the terms and conditions to create an account'); } const app = await Data.getEntry('onboarding_applications', input.applicationId); // Verify all steps completed if (!app.data.identityVerified) { throw new Error('Identity verification must be completed first'); } if (!app.data.qualifies) { throw new Error('Application is pending review'); } // Create account in your banking system (external API) const bankingApiKey = env('BANKING_API_KEY'); const accountResponse = await fetch('https://your-banking-api.com/api/accounts', { method: 'POST', headers: { 'Authorization': `Bearer ${bankingApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: { first_name: app.data.personalInfo.firstName, last_name: app.data.personalInfo.lastName, email: app.data.email, date_of_birth: app.data.personalInfo.dateOfBirth, ssn: app.data.personalInfo.ssn, // Encrypted address: app.data.personalInfo.address, phone: app.data.personalInfo.phone }, products: input.accountProducts, verification: { identity_verified: true, verification_id: app.data.verificationResult.verificationId, kyc_status: 'approved' }, suitability: app.data.suitabilityScore, metadata: { application_id: input.applicationId, onboarding_source: 'ai_agent' } }) }); if (!accountResponse.ok) { throw new Error('Failed to create account. Please contact support.'); } const account = await accountResponse.json(); // Update application as completed await Data.update('onboarding_applications', input.applicationId, { ...app.data, status: 'completed', accountId: account.account_id, accountNumber: account.account_number, products: input.accountProducts, completedSteps: [...app.data.completedSteps, 'account_creation'], completedAt: new Date().toISOString() }); return { success: true, accountId: account.account_id, accountNumber: account.account_number.replace(/\d(?=\d{4})/g, '*'), // Mask all but last 4 products: input.accountProducts, loginUrl: account.login_url, temporaryPassword: account.temporary_password, message: `🎉 Account created successfully! Your account number is ${account.account_number.slice(-4)}. Check your email for login credentials.`, nextSteps: [ "Check your email for account details", "Set up online banking at " + account.login_url, "Fund your account to start using services", "Download our mobile app for easy access" ] }; } } // 7. Check Onboarding Status export class CheckOnboardingStatusTool implements LuaTool { name = "check_onboarding_status"; description = "Check the status of an onboarding application"; inputSchema = z.object({ applicationId: z.string(), email: z.string().email().describe("Email for verification") }); async execute(input: z.infer) { const app = await Data.getEntry('onboarding_applications', input.applicationId); if (!app || app.data.email !== input.email) { throw new Error('Application not found or email mismatch'); } const stepStatus = { started: '🟢 Started', personal_info: app.data.completedSteps.includes('personal_info') ? '✅ Complete' : '⏳ Pending', document_upload: app.data.documents?.length > 0 ? '✅ Complete' : '⏳ Pending', identity_verification: app.data.identityVerified ? '✅ Verified' : '⏳ Pending', qualifying_questions: app.data.qualifyingAnswers ? '✅ Complete' : '⏳ Pending', account_creation: app.data.accountId ? '✅ Complete' : '⏳ Pending' }; return { applicationId: input.applicationId, status: app.data.status, currentStep: app.data.currentStep, progress: stepStatus, completedSteps: app.data.completedSteps, nextStep: this.getNextStepMessage(app.data.currentStep), estimatedCompletion: app.data.status === 'completed' ? 'Completed' : this.calculateEstimatedCompletion(app.data.completedSteps.length) }; } private getNextStepMessage(currentStep: string): string { const messages = { personal_info: "Please provide your personal information", document_upload: "Please upload your government-issued ID", identity_verification: "Verifying your identity...", qualifying_questions: "Please answer the qualifying questions", account_creation: "Ready to create your account!", under_review: "Application under manual review", completed: "Application complete!" }; return messages[currentStep] || "Continue with onboarding"; } private calculateEstimatedCompletion(completedSteps: number): string { const totalSteps = 5; const remaining = totalSteps - completedSteps; return `${remaining * 2} minutes`; } } ``` ## Environment Setup ```bash theme={null} # .env STRIPE_SECRET_KEY=sk_test_your_stripe_key BANKING_API_KEY=your_banking_api_key BANKING_API_URL=https://your-banking-api.com ``` ## Document Upload Flow ### Frontend Integration ```html theme={null}
``` ## Testing Conversation Flow ```bash theme={null} lua chat ``` Select sandbox mode, then test this **example conversation:** ``` User: "I want to open an investment account" AI: [Calls start_onboarding] "Great! Let's get you started. What's your email address?" User: "john@example.com" AI: [Calls collect_personal_info] "Perfect! I'll need some personal information. What's your full name?" User: "John Doe, DOB 1990-01-15, SSN 123-45-6789, address: 123 Main St..." AI: [Saves info] "Information saved securely. Now I need to verify your identity. Please upload a photo of your driver's license or passport." User: [Uploads document images] AI: [Calls upload_document, then verify_identity] "Document uploaded! Verifying your identity... ✅ Identity verified! Now, let's answer some questions about your financial goals..." User: "I make $75k/year, moderate experience, looking for long-term retirement..." AI: [Calls answer_qualifying_questions] "Based on your profile, you qualify! I recommend an IRA Account and Investment Account. Shall we create your account?" User: "Yes, create it" AI: [Calls create_account] "🎉 Account created! Your account number is ****5678. Check your email for login details." ``` ## Key Features Document verification API Application state management Regulatory compliance built-in Guided onboarding flow Suitability scoring Encrypted PII storage ## Compliance & Security **Regulatory Compliance Required** This demo shows technical implementation. For production: * ✅ Implement proper encryption for PII (use KMS, not base64) * ✅ Follow KYC/AML regulations (Bank Secrecy Act, Patriot Act) * ✅ Maintain audit logs of all data access * ✅ Use HTTPS only * ✅ Implement data retention policies * ✅ Follow GDPR/CCPA for data privacy * ✅ Store documents in compliant storage (encrypted at rest) * ✅ Conduct regular security audits * ✅ Implement fraud detection * ✅ Follow FinCEN guidelines ### Security Best Practices ```typescript theme={null} // Encrypt sensitive data before storage import crypto from 'crypto'; function encryptPII(data: string): string { const algorithm = 'aes-256-gcm'; const key = Buffer.from(env('ENCRYPTION_KEY'), 'hex'); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(algorithm, key, iv); let encrypted = cipher.update(data, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); return JSON.stringify({ encrypted, iv: iv.toString('hex'), authTag: authTag.toString('hex') }); } // Use in your tools const encryptedSSN = encryptPII(input.personalInfo.ssn); ``` ## Alternative Document Verification Services This demo uses Stripe Identity, but you can swap with: ```typescript theme={null} const onfidoApiKey = env('ONFIDO_API_KEY'); const response = await fetch('https://api.onfido.com/v3/applicants', { method: 'POST', headers: { 'Authorization': `Token token=${onfidoApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ first_name: input.firstName, last_name: input.lastName, email: input.email }) }); ``` ```typescript theme={null} const jumioApiKey = env('JUMIO_API_TOKEN'); const response = await fetch('https://netverify.com/api/v4/initiate', { headers: { 'Authorization': `Bearer ${jumioApiKey}`, 'User-Agent': 'YourCompany/1.0.0' } }); ``` ```typescript theme={null} const plaidKey = env('PLAID_CLIENT_ID'); const response = await fetch('https://production.plaid.com/identity/get', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: plaidKey, secret: env('PLAID_SECRET'), access_token: userAccessToken }) }); ``` ## Document Types Supported * Driver's License (front and back) * State ID * Passport * National ID card **Verification checks:** * Document authenticity * Face match with selfie * Data extraction (name, DOB, address) * Expiration date validation * Utility bill (within 3 months) * Bank statement * Lease agreement * Government correspondence **Verification checks:** * Address matches ID * Document date within acceptable range * Name matches applicant * Bank statements * Tax returns (for high-value accounts) * Pay stubs (employment verification) * Investment account statements **Used for:** * Income verification * Source of funds * Net worth assessment ## Onboarding Journey Diagram ``` 1. Start Application ↓ 2. Collect Personal Info ↓ 3. Upload Documents (ID + Proof of Address) ↓ 4. Identity Verification (Stripe Identity API) ↓ 5. Qualifying Questions (Risk Assessment) ↓ 6. Suitability Check ↓ 7. Create Account (if qualified) ✅ Account Active ``` ## Customization ### Add Additional Verification ```typescript theme={null} // Add selfie verification export class CaptureSelfie Tool extends LuaTool { async execute(input: { applicationId: string, selfieUrl: string }) { // Upload selfie to Stripe const response = await fetch('https://api.stripe.com/v1/identity/verification_sessions', { method: 'POST', body: new URLSearchParams({ type: 'selfie', metadata: { application_id: input.applicationId } }) }); // Stripe compares selfie with ID photo return { verified: true }; } } ``` ### Add Fraud Checks ```typescript theme={null} // Integrate with fraud detection service const fraudCheck = await fetch('https://fraud-api.com/check', { method: 'POST', body: JSON.stringify({ email: app.data.email, ip_address: userIp, device_fingerprint: deviceId }) }); if (fraudCheck.risk_score > 0.7) { // Flag for manual review await Data.update(applicationId, { ...app.data, status: 'fraud_review', flaggedForReview: true }); } ``` ## Key Takeaways Guided journey with state management Stripe Identity + Lua Data KYC/AML patterns shown Encryption and security practices ## Production Considerations * Keep applications for 7 years (regulatory requirement) * Implement automated data deletion for rejected applications * Archive completed applications to cold storage ```typescript theme={null} await Data.create('audit_logs', { action: 'document_uploaded', applicationId: input.applicationId, timestamp: new Date().toISOString(), ipAddress: userIp, userAgent: userAgent }); ``` * Regular review of declined applications * Monthly compliance reports * Suspicious activity reporting (SAR) * Customer due diligence (CDD) ## Next Demo See internal employee management with BambooHR # Healthcare Patient Portal Source: https://docs.heylua.ai/demos/healthcare-portal Patient services with EMR API integration ## Overview Healthcare patient portal integrating with **Electronic Medical Records (EMR) API** for appointments, prescriptions, and medical records. **What it does:** * Schedule appointments * View medical records * Request prescription refills * Access test results * Message healthcare providers **APIs used:** EMR/FHIR API (external) + Lua Data API ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, PreProcessor, PostProcessor } from "lua-cli"; import { ScheduleAppointmentTool, ViewMedicalRecordsTool, RequestPrescriptionRefillTool, MessageProviderTool } from "./tools/HealthcareTools"; // Healthcare skill const healthcareSkill = new LuaSkill({ name: "healthcare-portal", description: "Patient portal services for appointments and medical records", context: ` This skill helps patients manage their healthcare. - schedule_appointment: Book medical appointments - view_medical_records: Access patient records (HIPAA compliant) - request_prescription_refill: Request medication refills - message_provider: Send secure messages to healthcare providers Always verify patient identity. Maintain HIPAA compliance. Be compassionate and professional. `, tools: [ new ScheduleAppointmentTool(), new ViewMedicalRecordsTool(), new RequestPrescriptionRefillTool(), new MessageProviderTool() ] }); // HIPAA consent validator const hipaaConsentPreProcessor = new PreProcessor({ name: 'hipaa-consent', description: 'Verify patient has consented to access medical information', execute: async (message, user) => { if (!user.data.hipaaConsentGiven) { return { block: true, response: "Before accessing your medical information, please confirm you consent to HIPAA terms. Reply 'I consent' to continue." }; } return { block: false }; } }); // Medical disclaimer postprocessor const medicalDisclaimerPostProcessor = new PostProcessor({ name: 'medical-disclaimer', description: 'Add medical disclaimer to all responses', execute: async (user, message, response, channel) => { return { modifiedResponse: response + "\n\n⚕️ **Medical Disclaimer:** This information is for patient portal access only. Always consult your healthcare provider for medical advice. In case of emergency, call 911." }; } }); // Configure agent export const agent = new LuaAgent({ name: "healthcare-portal-assistant", persona: `You are a compassionate healthcare patient portal assistant. Your role: - Help patients schedule appointments - Provide access to medical records - Assist with prescription refills - Facilitate communication with providers - Guide through portal features Communication style: - Compassionate and reassuring - Professional and respectful - Clear and patient - Privacy-conscious - HIPAA compliant Compliance requirements: - Verify patient identity - Maintain HIPAA compliance - Protect patient privacy - Secure all communications - Document all interactions Best practices: - Always verify patient consent before accessing records - Explain medical terminology in simple terms - Provide clear appointment instructions - Remind about prescription pickup locations - Encourage emergency services when appropriate When to escalate: - Medical emergencies (direct to 911) - Complex medical questions (refer to provider) - Billing disputes (refer to billing department) - Insurance questions (refer to insurance coordinator)`, skills: [healthcareSkill], preProcessors: [hipaaConsentPreProcessor], postProcessors: [medicalDisclaimerPostProcessor] }); ``` This demo uses `LuaAgent` with preprocessors for HIPAA consent validation and postprocessors for medical disclaimers. ### src/tools/HealthcareTools.ts ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Schedule Appointment (External EMR API) export class ScheduleAppointmentTool implements LuaTool { name = "schedule_appointment"; description = "Schedule a medical appointment"; inputSchema = z.object({ patientId: z.string(), providerId: z.string(), appointmentType: z.enum(['checkup', 'specialist', 'follow-up', 'urgent']), preferredDate: z.string(), preferredTime: z.string(), reason: z.string() }); async execute(input: z.infer) { const emrApiKey = env('EMR_API_KEY'); const emrBaseUrl = env('EMR_API_URL'); // Call FHIR-compliant EMR API const response = await fetch(`${emrBaseUrl}/fhir/Appointment`, { method: 'POST', headers: { 'Authorization': `Bearer ${emrApiKey}`, 'Content-Type': 'application/fhir+json' }, body: JSON.stringify({ resourceType: 'Appointment', status: 'proposed', serviceType: [{ text: input.appointmentType }], participant: [ { actor: { reference: `Patient/${input.patientId}` } }, { actor: { reference: `Practitioner/${input.providerId}` } } ], requestedPeriod: [{ start: `${input.preferredDate}T${input.preferredTime}:00`, end: `${input.preferredDate}T${this.addHour(input.preferredTime)}:00` }], reason: [{ text: input.reason }] }) }); const appointment = await response.json(); return { success: true, appointmentId: appointment.id, date: input.preferredDate, time: input.preferredTime, provider: input.providerId, type: input.appointmentType, message: `Appointment scheduled for ${input.preferredDate} at ${input.preferredTime}` }; } private addHour(time: string): string { const [hours, minutes] = time.split(':'); const newHour = (parseInt(hours) + 1).toString().padStart(2, '0'); return `${newHour}:${minutes}`; } } // 2. View Medical Records (External EMR API) export class ViewMedicalRecordsTool implements LuaTool { name = "view_medical_records"; description = "Access patient medical records"; inputSchema = z.object({ patientId: z.string(), recordType: z.enum(['allergies', 'medications', 'conditions', 'procedures']).optional() }); async execute(input: z.infer) { const emrApiKey = env('EMR_API_KEY'); const emrBaseUrl = env('EMR_API_URL'); const endpoint = input.recordType || 'Patient'; const response = await fetch( `${emrBaseUrl}/fhir/${endpoint}/${input.patientId}`, { headers: { 'Authorization': `Bearer ${emrApiKey}`, 'Accept': 'application/fhir+json' } } ); const data = await response.json(); return { patientId: input.patientId, recordType: input.recordType || 'summary', lastUpdated: data.meta?.lastUpdated, summary: this.formatMedicalData(data), message: "Medical records retrieved. Always consult with your healthcare provider." }; } private formatMedicalData(data: any) { // Format EMR data for patient-friendly display return { allergies: data.allergy || [], medications: data.medicationStatement || [], conditions: data.condition || [] }; } } // 3. Request Prescription Refill (External EMR + Pharmacy API) export class RefillPrescriptionTool implements LuaTool { name = "refill_prescription"; description = "Request prescription refill"; inputSchema = z.object({ patientId: z.string(), prescriptionId: z.string(), pharmacyId: z.string(), deliveryMethod: z.enum(['pickup', 'delivery']).default('pickup') }); async execute(input: z.infer) { const emrApiKey = env('EMR_API_KEY'); const pharmacyApiKey = env('PHARMACY_API_KEY'); // Request refill through pharmacy API const response = await fetch('https://pharmacy-api.com/api/v1/refills', { method: 'POST', headers: { 'Authorization': `Bearer ${pharmacyApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ patient_id: input.patientId, prescription_id: input.prescriptionId, pharmacy_id: input.pharmacyId, delivery_method: input.deliveryMethod }) }); const refill = await response.json(); return { success: true, refillId: refill.id, status: refill.status, estimatedReady: refill.estimated_ready_time, deliveryMethod: input.deliveryMethod, message: input.deliveryMethod === 'pickup' ? `Prescription will be ready for pickup in ${refill.estimated_ready_time}` : `Prescription will be delivered to your address` }; } } // 4. Search Health Info (Lua Data - Vector Search) export class SearchHealthInfoTool implements LuaTool { name = "search_health_info"; description = "Search health information and educational content"; inputSchema = z.object({ query: z.string().describe("Health question or topic") }); async execute(input: z.infer) { // Use vector search for health information const results = await Data.search('health_articles', input.query, 5, 0.75); return { articles: results.map(entry => ({ title: entry.title, summary: entry.summary, category: entry.category, source: entry.source, url: entry.url, relevance: entry.score })), disclaimer: "This information is educational. Always consult your healthcare provider for medical advice." }; } } ``` ## Environment Setup ```bash theme={null} # .env EMR_API_KEY=your_emr_api_key EMR_API_URL=https://your-emr-system.com PHARMACY_API_KEY=your_pharmacy_api_key ``` ## Security & Compliance **HIPAA Compliance Required** This example shows the technical integration. For production: * Implement proper authentication (OAuth 2.0) * Encrypt all PHI (Protected Health Information) * Maintain audit logs * Use secure connections only * Follow HIPAA guidelines ## Key Features FHIR-compliant API HIPAA considerations Health info search EMR + Pharmacy APIs # Hotel Booking Agent Source: https://docs.heylua.ai/demos/hotel-booking Reservation management with Lua Data API ## Overview Hotel concierge AI agent for room bookings, reservations, and guest services using **Lua Data API**. **What it does:** * Check room availability * Create reservations * Modify bookings * Request room service * Provide local recommendations **APIs used:** Lua Data API for reservations and rooms ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { CheckAvailabilityTool, CreateReservationTool, GetReservationTool, CancelReservationTool, RoomServiceTool } from "./tools/HotelTools"; // Hotel concierge skill const hotelSkill = new LuaSkill({ name: "hotel-concierge", description: "Hotel booking and concierge services", context: ` This skill helps guests with hotel bookings and services. - check_availability: Search for available rooms by date and guest count - create_reservation: Book a room for specified dates - get_reservation: Look up existing reservation details - cancel_reservation: Cancel a booking - room_service: Order room service or request amenities Always confirm dates and guest count before booking. Offer upgrade suggestions when available. Be warm and hospitable. `, tools: [ new CheckAvailabilityTool(), new CreateReservationTool(), new GetReservationTool(), new CancelReservationTool(), new RoomServiceTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "hotel-concierge-assistant", persona: `You are a friendly and professional hotel concierge. Your role: - Help guests find and book the perfect room - Assist with reservations and modifications - Provide room service and amenity requests - Offer local recommendations and directions - Ensure excellent guest experience Communication style: - Warm and hospitable - Professional and attentive - Proactive with suggestions - Detail-oriented - Service-minded Best practices: - Always confirm check-in and check-out dates - Mention amenities included with each room type - Offer upgrades when available - Ask about special occasions (anniversary, birthday) - Provide clear cancellation policies - Suggest local attractions and dining When to escalate: - VIP guest requests - Complex group bookings - Special event coordination - Maintenance issues`, skills: [hotelSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills. ### src/tools/HotelTools.ts ```typescript theme={null} import { LuaTool, Data } from "lua-cli"; import { z } from "zod"; // 1. Check Availability export class CheckAvailabilityTool implements LuaTool { name = "check_availability"; description = "Check room availability for specific dates"; inputSchema = z.object({ checkIn: z.string().describe("Check-in date (YYYY-MM-DD)"), checkOut: z.string().describe("Check-out date (YYYY-MM-DD)"), guests: z.number().min(1).max(10).describe("Number of guests") }); async execute(input: z.infer) { // Get all rooms const rooms = await Data.get('hotel_rooms', {}, 1, 100); // Get existing reservations for these dates const reservations = await Data.get('reservations', { checkIn: { $lte: input.checkOut }, checkOut: { $gte: input.checkIn }, status: { $ne: 'cancelled' } }); const bookedRoomIds = new Set(reservations.data.map(r => r.data.roomId)); // Filter available rooms const available = rooms.data .filter(room => !bookedRoomIds.has(room.id)) .filter(room => room.data.maxGuests >= input.guests) .map(room => ({ roomId: room.id, roomType: room.data.type, maxGuests: room.data.maxGuests, pricePerNight: `$${room.data.pricePerNight}`, amenities: room.data.amenities, description: room.data.description })); const nights = this.calculateNights(input.checkIn, input.checkOut); return { available: available.length > 0, rooms: available, checkIn: input.checkIn, checkOut: input.checkOut, nights, message: available.length > 0 ? `${available.length} rooms available for ${nights} nights` : "No rooms available for these dates. Try different dates?" }; } private calculateNights(checkIn: string, checkOut: string): number { const start = new Date(checkIn); const end = new Date(checkOut); return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); } } // 2. Create Reservation export class CreateReservationTool implements LuaTool { name = "create_reservation"; description = "Book a hotel room"; inputSchema = z.object({ roomId: z.string(), checkIn: z.string(), checkOut: z.string(), guestName: z.string(), guestEmail: z.string().email(), guestPhone: z.string(), guests: z.number().min(1), specialRequests: z.string().optional() }); async execute(input: z.infer) { // Get room details const room = await Data.getEntry('hotel_rooms', input.roomId); if (!room) { throw new Error('Room not found'); } // Calculate total const nights = this.calculateNights(input.checkIn, input.checkOut); const totalPrice = room.data.pricePerNight * nights; // Create reservation const reservation = await Data.create('reservations', { roomId: input.roomId, roomType: room.data.type, checkIn: input.checkIn, checkOut: input.checkOut, guestName: input.guestName, guestEmail: input.guestEmail, guestPhone: input.guestPhone, numberOfGuests: input.guests, nights, pricePerNight: room.data.pricePerNight, totalPrice, specialRequests: input.specialRequests, status: 'confirmed', confirmationCode: this.generateConfirmationCode(), createdAt: new Date().toISOString() }, `${input.guestName} ${input.guestEmail} ${input.checkIn}`); return { success: true, reservationId: reservation.id, confirmationCode: reservation.data.confirmationCode, roomType: room.data.type, checkIn: input.checkIn, checkOut: input.checkOut, nights, total: `$${totalPrice.toFixed(2)}`, message: `Reservation confirmed! Your confirmation code is ${reservation.data.confirmationCode}` }; } private calculateNights(checkIn: string, checkOut: string): number { const start = new Date(checkIn); const end = new Date(checkOut); return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); } private generateConfirmationCode(): string { return 'HTL-' + Math.random().toString(36).substring(2, 10).toUpperCase(); } } // 3. Get Reservation export class GetReservationTool implements LuaTool { name = "get_reservation"; description = "Look up reservation details"; inputSchema = z.object({ confirmationCode: z.string().describe("Reservation confirmation code"), email: z.string().email().describe("Guest email for verification") }); async execute(input: z.infer) { // Search reservations const results = await Data.get('reservations', { confirmationCode: input.confirmationCode, guestEmail: input.email }); if (results.data.length === 0) { throw new Error('Reservation not found. Please check your confirmation code and email.'); } const reservation = results.data[0]; return { reservationId: reservation.id, confirmationCode: reservation.data.confirmationCode, guestName: reservation.data.guestName, roomType: reservation.data.roomType, checkIn: reservation.data.checkIn, checkOut: reservation.data.checkOut, nights: reservation.data.nights, guests: reservation.data.numberOfGuests, total: `$${reservation.data.totalPrice.toFixed(2)}`, status: reservation.data.status, specialRequests: reservation.data.specialRequests }; } } // 4. Cancel Reservation export class CancelReservationTool implements LuaTool { name = "cancel_reservation"; description = "Cancel a hotel reservation"; inputSchema = z.object({ confirmationCode: z.string(), email: z.string().email() }); async execute(input: z.infer) { const results = await Data.get('reservations', { confirmationCode: input.confirmationCode, guestEmail: input.email }); if (results.data.length === 0) { throw new Error('Reservation not found'); } const reservation = results.data[0]; // Update status to cancelled await Data.update('reservations', reservation.id, { ...reservation.data, status: 'cancelled', cancelledAt: new Date().toISOString() }); return { success: true, confirmationCode: input.confirmationCode, refundAmount: `$${reservation.data.totalPrice.toFixed(2)}`, message: "Reservation cancelled. Refund will be processed within 5-7 business days." }; } } // 5. Room Service export class RoomServiceTool implements LuaTool { name = "room_service"; description = "Order room service or request amenities"; inputSchema = z.object({ confirmationCode: z.string(), request: z.string().describe("Room service order or amenity request") }); async execute(input: z.infer) { // Log room service request const request = await Data.create('room_service_requests', { confirmationCode: input.confirmationCode, request: input.request, status: 'pending', requestedAt: new Date().toISOString() }, input.request); return { success: true, requestId: request.id, estimatedTime: "15-30 minutes", message: `Room service request received. Estimated delivery: 15-30 minutes.` }; } } ``` ## Deployment ```bash theme={null} lua push lua deploy ``` ## Key Features Stores rooms and reservations Search reservations by guest info Availability checking Unique booking identifiers # HR Operations Assistant Source: https://docs.heylua.ai/demos/hr-assistant Internal HR automation with BambooHR API + Lua Data ## Overview Internal HR assistant integrating with **BambooHR API** for employee management and **Lua Data API** for company policies and documents. **What it does:** * Look up employee information * Request time off * Search HR policies * Onboard new employees * Answer benefits questions **APIs used:** BambooHR API (external) + Lua Data API (policies) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaJob } from "lua-cli"; import { SearchPoliciesTool, GetEmployeeInfoTool, RequestTimeOffTool } from "./tools/HRTools"; // HR skill const hrSkill = new LuaSkill({ name: "hr-operations", description: "Internal HR operations and employee services", context: ` This skill helps employees with HR-related requests. - search_policies: Search company policies and procedures - get_employee_info: Look up employee information - request_time_off: Submit time off requests Always be helpful and professional. Ensure confidentiality of employee information. `, tools: [ new SearchPoliciesTool(), new GetEmployeeInfoTool(), new RequestTimeOffTool() ] }); // Daily attendance report job const attendanceReportJob = new LuaJob({ name: 'daily-attendance-report', description: 'Send daily attendance summary', schedule: { type: 'cron', pattern: '0 17 * * 1-5' // Every weekday at 5 PM }, execute: async (job) => { const user = await job.user(); await user.send([{ type: 'text', text: '📊 Daily attendance report is ready. All employees accounted for.' }]); } }); // Configure agent export const agent = new LuaAgent({ name: "hr-operations-assistant", persona: `You are a professional and helpful HR assistant. Your role: - Help employees find HR policies and procedures - Look up employee information - Process time off requests - Answer benefits and payroll questions - Guide through HR processes Communication style: - Professional and friendly - Confidential and trustworthy - Clear and helpful - Empathetic and supportive Best practices: - Maintain confidentiality of employee information - Provide accurate policy information - Direct employees to appropriate resources - Confirm details before processing requests - Follow company HR policies strictly When to escalate: - Sensitive employee relations issues - Compensation discussions - Disciplinary matters - Legal or compliance questions`, skills: [hrSkill], jobs: [attendanceReportJob] }); ``` This demo uses `LuaAgent` with scheduled jobs for daily attendance reports. ### src/tools/HRTools.ts ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Search HR Policies (Lua Vector Search) export class SearchPoliciesTool implements LuaTool { name = "search_policies"; description = "Search company HR policies and procedures"; inputSchema = z.object({ query: z.string().describe("Policy question or keyword") }); async execute(input: z.infer) { const results = await Data.search('hr_policies', input.query, 5, 0.7); return { policies: results.map(entry => ({ title: entry.title, summary: entry.content.substring(0, 200) + '...', category: entry.category, lastUpdated: entry.updatedAt, relevance: `${Math.round(entry.score * 100)}%` })), count: results.length }; } } // 2. Get Employee Info (BambooHR External API) export class GetEmployeeInfoTool implements LuaTool { name = "get_employee_info"; description = "Look up employee information"; inputSchema = z.object({ employeeId: z.string() }); async execute(input: z.infer) { const bambooApiKey = env('BAMBOOHR_API_KEY'); const bambooSubdomain = env('BAMBOOHR_SUBDOMAIN'); const response = await fetch( `https://api.bamboohr.com/api/gateway.php/${bambooSubdomain}/v1/employees/${input.employeeId}`, { headers: { 'Authorization': `Basic ${Buffer.from(bambooApiKey + ':x').toString('base64')}`, 'Accept': 'application/json' } } ); if (!response.ok) { throw new Error('Employee not found'); } const employee = await response.json(); return { employeeId: employee.id, name: `${employee.firstName} ${employee.lastName}`, department: employee.department, jobTitle: employee.jobTitle, hireDate: employee.hireDate, manager: employee.supervisor, workEmail: employee.workEmail }; } } // 3. Request Time Off (BambooHR External API) export class RequestTimeOffTool implements LuaTool { name = "request_time_off"; description = "Submit a time off request"; inputSchema = z.object({ employeeId: z.string(), startDate: z.string(), endDate: z.string(), timeOffType: z.enum(['vacation', 'sick', 'personal']), notes: z.string().optional() }); async execute(input: z.infer) { const bambooApiKey = env('BAMBOOHR_API_KEY'); const bambooSubdomain = env('BAMBOOHR_SUBDOMAIN'); const response = await fetch( `https://api.bamboohr.com/api/gateway.php/${bambooSubdomain}/v1/employees/${input.employeeId}/time_off/request`, { method: 'POST', headers: { 'Authorization': `Basic ${Buffer.from(bambooApiKey + ':x').toString('base64')}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ start: input.startDate, end: input.endDate, timeOffTypeId: input.timeOffType, notes: input.notes }) } ); const data = await response.json(); return { success: true, requestId: data.id, status: 'pending_approval', message: "Time off request submitted. You'll be notified when approved." }; } } ``` ## Environment Setup ```bash theme={null} # .env BAMBOOHR_API_KEY=your_bamboohr_api_key BAMBOOHR_SUBDOMAIN=your_company ``` ## Key Features BambooHR integration Policy semantic search # IoT Security Camera Source: https://docs.heylua.ai/demos/iot-camera Capture photos on-demand with Raspberry Pi Camera via chat ## Overview Capture still images from a **Raspberry Pi Camera Module** through natural language commands. Take photos on-demand, schedule periodic snapshots, or trigger captures from webhooks. **What it does:** * Capture photos via chat commands * Save images with timestamps * Schedule periodic snapshots * Webhook-triggered captures (motion detection, etc.) **Hardware:** Raspberry Pi 4/5, Camera Module 3/HQ (CSI connection) **APIs used:** Custom Edge API using rpicam-still *** ## Architecture ``` User Chat → Lua Agent → TakePhotoTool → Edge API → rpicam-still → Image File ``` *** ## Complete Implementation ### Edge API on Raspberry Pi #### Setup (one-time) ```bash theme={null} # Install OS packages (rpicam-* apps are included in Raspberry Pi OS Bookworm) sudo apt update sudo apt install -y python3-pip python3-venv # Create project folder mkdir -p ~/iot-edge && cd ~/iot-edge python3 -m venv .venv source .venv/bin/activate # Install Flask pip install flask # Create output directory for photos mkdir -p ~/camera-snapshots ``` **Camera on Bookworm:** The modern `rpicam-still` command is included by default. Old `libcamera-still` is now a symlink to `rpicam-still`. #### Edge API Code Update `edge_api.py` (or add to existing): ```python theme={null} from flask import Flask, request, jsonify from functools import wraps import os, time, subprocess app = Flask(__name__) API_KEY = os.environ.get("EDGE_API_KEY", "changeme") def require_key(fn): @wraps(fn) def wrapper(*args, **kwargs): if request.headers.get("X-API-Key") != API_KEY: return jsonify({"error": "unauthorized"}), 401 return fn(*args, **kwargs) return wrapper @app.get("/health") def health(): return {"ok": True, "ts": int(time.time())} @app.post("/camera/snap") @require_key def camera_snap(): data = request.get_json(force=True, silent=True) or {} outdir = data.get("outdir", "/home/pi/camera-snapshots") os.makedirs(outdir, exist_ok=True) filename = time.strftime("snap_%Y%m%d_%H%M%S.jpg") path = os.path.join(outdir, filename) # Use rpicam-still (modern camera CLI on Bookworm) timeout_ms = int(data.get("timeout_ms", 1000)) width = int(data.get("width", 1920)) height = int(data.get("height", 1080)) try: subprocess.run([ "rpicam-still", "-t", str(timeout_ms), "-o", path, "--width", str(width), "--height", str(height) ], check=True, capture_output=True) return { "success": True, "path": path, "filename": filename, "size_bytes": os.path.getsize(path) } except subprocess.CalledProcessError as e: return jsonify({ "error": "Camera capture failed", "details": e.stderr.decode() }), 500 if __name__ == "__main__": app.run(host="0.0.0.0", port=5001) ``` #### Run Edge API ```bash theme={null} export EDGE_API_KEY="supersecret" python edge_api.py ``` #### Test Edge API ```bash theme={null} curl -X POST http://raspberrypi.local:5001/camera/snap \ -H "X-API-Key: supersecret" \ -H "Content-Type: application/json" \ -d '{"timeout_ms":2000,"width":1920,"height":1080}' ``` *** ### Lua Agent Implementation #### src/tools/TakePhotoTool.ts ```typescript theme={null} import { LuaTool, Data, env } from 'lua-cli'; import { z } from 'zod'; export default class TakePhotoTool implements LuaTool { name = "take_photo"; description = "Capture a still image with the Raspberry Pi camera"; inputSchema = z.object({ timeout_ms: z.number().int().default(1000).describe("Preview time before capture (ms)"), width: z.number().int().default(1920).describe("Image width"), height: z.number().int().default(1080).describe("Image height"), outdir: z.string().default("/home/pi/camera-snapshots").describe("Output directory") }); async execute(input: z.infer) { const base = env('PI_BASE_URL'); const key = env('PI_API_KEY'); if (!base || !key) { throw new Error('PI_BASE_URL or PI_API_KEY not configured'); } const res = await fetch(`${base}/camera/snap`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': key }, body: JSON.stringify(input) }); if (!res.ok) { throw new Error(`Camera error: ${res.status} ${await res.text()}`); } const result = await res.json(); // Log photo in Lua Data await Data.create('camera_snapshots', { filename: result.filename, path: result.path, size_bytes: result.size_bytes, width: input.width, height: input.height, capturedAt: new Date().toISOString() }, `camera photo ${result.filename}`); return { success: true, filename: result.filename, path: result.path, size: `${Math.round(result.size_bytes / 1024)} KB`, resolution: `${input.width}×${input.height}`, message: `Photo captured: ${result.filename}` }; } } ``` #### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaJob, LuaWebhook } from 'lua-cli'; import TakePhotoTool from './tools/TakePhotoTool'; // Camera control skill const cameraSkill = new LuaSkill({ name: "pi-camera", description: "Raspberry Pi camera control and snapshot capture", context: ` This skill controls a Raspberry Pi camera. - take_photo: Capture a still image Use when user asks for a photo, snapshot, or image Always confirm photo was captured successfully. Mention filename and size in response. `, tools: [new TakePhotoTool()] }); // Scheduled job: Daily snapshot at noon const dailySnapshotJob = new LuaJob({ name: 'daily-snapshot', description: 'Capture daily photo at noon', schedule: { type: 'cron', pattern: '0 12 * * *' // Every day at 12 PM }, execute: async (job) => { const tool = new TakePhotoTool(); const result = await tool.execute({ timeout_ms: 2000, width: 1920, height: 1080, outdir: '/home/pi/camera-snapshots' }); const user = await job.user(); await user.send([{ type: 'text', text: `📷 Daily snapshot captured: ${result.filename} (${result.size})` }]); } }); // Webhook: Motion-triggered capture const motionWebhook = new LuaWebhook({ name: 'motion-triggered-capture', description: 'Capture photo when motion is detected', execute: async (event) => { if (event.type === 'motion.detected') { const tool = new TakePhotoTool(); const result = await tool.execute({ timeout_ms: 500 }); const user = await User.get(); await user.send([{ type: 'text', text: `🚨 Motion detected! Photo captured: ${result.filename}` }]); } return { received: true }; } }); // Configure agent export const agent = new LuaAgent({ name: "camera-monitor", persona: `You are a security camera monitoring assistant. Your role: - Capture photos on demand - Monitor for motion events - Provide photo confirmations - Track snapshot history Communication style: - Quick and confirmatory - Security-focused - Clear about photo details Best practices: - Confirm photo capture immediately - Mention filename and size - Alert on motion detection - Provide photo timestamps Camera knowledge: - Resolution: 1920×1080 (Full HD) - Format: JPEG - Storage: Local Pi storage - Retention: Configurable When to alert: - Motion detected - Storage running low - Camera errors`, skills: [cameraSkill], jobs: [dailySnapshotJob], webhooks: [motionWebhook] }); ``` Uses LuaAgent with scheduled jobs for daily snapshots and webhooks for motion-triggered captures. *** ## Environment Setup ```bash theme={null} # .env PI_BASE_URL=http://raspberrypi.local:5001 PI_API_KEY=supersecret ``` *** ## Camera Setup ### Physical Connection Connect Camera Module 3 or HQ to the CSI port on Raspberry Pi. Ensure ribbon cable is firmly seated. ### Verify Camera ```bash theme={null} # Test camera (Bookworm uses rpicam-still) rpicam-still -t 2000 -o test.jpg # Check image ls -lh test.jpg ``` **Troubleshooting:** If camera is not detected, check ribbon cable connection and run `vcgencmd get_camera` to verify detection. *** ## Testing ```bash theme={null} # Test tool lua test # Select: take_photo # Test conversationally lua chat # You: "Take a photo of the front door" # You: "Capture an image now" # You: "Take a high-res snapshot" ``` *** ## Key Features Take photos via natural language commands Daily photos at specified times Webhook integration for motion sensors Track all captures in Lua Data *** ## Advanced: Motion Detection Integration If you add a PIR motion sensor, you can trigger the webhook: ```python theme={null} # In edge_api.py, add motion detection endpoint from gpiozero import MotionSensor @app.post("/motion/trigger-webhook") @require_key def motion_trigger(): webhook_url = os.environ.get("MOTION_WEBHOOK_URL") if webhook_url: requests.post(webhook_url, json={"type": "motion.detected", "timestamp": time.time()}) return {"triggered": True} ``` Your Lua webhook will receive the event and capture a photo automatically. *** ## Next Steps See all 3 Raspberry Pi examples # IoT Door Access Control Source: https://docs.heylua.ai/demos/iot-door-assistant WhatsApp-controlled door locks with Raspberry Pi for hotels and apartments ## Overview Production-ready door access control system where **guests WhatsApp your agent to open doors**. Perfect for hotels, apartments, coworking spaces, or any building requiring secure access control. **What it does:** * Guests WhatsApp to unlock doors * Verify guest access permissions * Time-window access control * Audit logging of all unlocks * Staff tools for guest registration **Hardware:** Raspberry Pi 4/5, relay module, 12V electric strike or maglock **APIs used:** Lua WhatsApp channel + Edge API (Flask) + Lua Data API (guest management) *** ## Architecture ``` WhatsApp Message → Lua Agent → Check Access → Unlock Door Tool → Pi Edge API → Relay → Door Strike ↓ Lua Data (Guests) ``` **Security-First Design:** Always verify guest access before unlocking. This demo includes time-window validation, rate limiting, and audit logging. *** ## Hardware Setup ### Components Main controller running Edge API 3.3V-compatible, optocoupled (active-low) 12V fail-secure strike or maglock Separate 12V PSU for the lock ### Wiring ``` Raspberry Pi Relay Module Electric Strike ----------- ------------- --------------- 3.3V ────────> VCC GND ────────> GND GPIO 17 ────────> IN (Signal) COM ───────────────> 12V+ (from PSU) NO ────────────────> Strike + Strike - ──────────> 12V- (from PSU) ``` **Safety Critical:** * Use optocoupled relay module for isolation * NEVER power lock from Pi (use separate 12V PSU) * Add flyback diode across lock coil * Use fail-secure locks (locked when de-energized) * Keep unlock pulses short (2-5 seconds) *** ## Complete Implementation ### 1. Raspberry Pi Edge API #### Setup ```bash theme={null} # Install dependencies (Raspberry Pi OS Bookworm) sudo apt update sudo apt install -y python3-pip python3-venv python3-libgpiod # Add user to gpio group (allows GPIO without sudo) sudo adduser $USER gpio # Log out and back in for this to take effect # Create project mkdir -p ~/door-edge && cd ~/door-edge python3 -m venv .venv source .venv/bin/activate pip install flask gpiozero ``` #### Edge API Code Create `edge_api.py`: ```python theme={null} from flask import Flask, request, jsonify import os, time from gpiozero import OutputDevice API_KEY = os.environ.get("EDGE_API_KEY", "changeme") DEFAULT_PIN = int(os.environ.get("DOOR_PIN", "17")) # BCM numbering ACTIVE_LOW = os.environ.get("ACTIVE_LOW", "true").lower() == "true" DEFAULT_UNLOCK_MS = int(os.environ.get("UNLOCK_MS", "3000")) app = Flask(__name__) _last_unlock = 0 def authorized(req): return req.headers.get("X-API-Key") == API_KEY @app.get("/health") def health(): return {"ok": True, "ts": int(time.time())} @app.post("/door/unlock") def door_unlock(): if not authorized(request): return jsonify({"error": "unauthorized"}), 401 global _last_unlock now = time.time() # Rate limiting: prevent unlocks within 2 seconds if now - _last_unlock < 2: return {"ok": False, "rate_limited": True}, 429 data = request.get_json(force=True, silent=True) or {} pin = int(data.get("pin", DEFAULT_PIN)) ms = int(data.get("ms", DEFAULT_UNLOCK_MS)) active_low = bool(data.get("active_low", ACTIVE_LOW)) # Validate unlock duration (safety) if ms < 500 or ms > 10000: return {"error": "Invalid unlock duration (500-10000ms)"}, 400 dev = OutputDevice(pin, active_high=not active_low, initial_value=False) try: dev.on() # Energize relay → unlock time.sleep(ms / 1000.0) dev.off() # De-energize → relock _last_unlock = time.time() return { "ok": True, "pin": pin, "ms": ms, "active_low": active_low, "timestamp": int(time.time()) } finally: dev.close() if __name__ == "__main__": app.run(host="0.0.0.0", port=5001) ``` #### Run Edge API ```bash theme={null} export EDGE_API_KEY="supersecret" export DOOR_PIN=17 export ACTIVE_LOW=true export UNLOCK_MS=3000 python edge_api.py ``` #### Test Edge API ```bash theme={null} # Health check curl http://raspberrypi.local:5001/health # Test unlock curl -X POST http://raspberrypi.local:5001/door/unlock \ -H "X-API-Key: supersecret" \ -H "Content-Type: application/json" \ -d '{"pin":17,"ms":3000,"active_low":true}' ``` *** ### 2. Lua Agent Implementation #### Environment Variables ```bash theme={null} # .env PI_BASE_URL=http://raspberrypi.local:5001 PI_API_KEY=supersecret DOOR_MAP_JSON={"front":17,"garage":27} DEFAULT_UNLOCK_MS=3000 ACTIVE_LOW=true BUILDING_ID=hotel-abc ``` Or set via CLI: ```bash theme={null} lua env sandbox # Add all the above variables ``` #### src/tools/CheckAccessTool.ts ```typescript theme={null} import { LuaTool, Data, env } from 'lua-cli'; import { z } from 'zod'; export default class CheckAccessTool implements LuaTool { name = "check_access"; description = "Verify if a phone number has active access to a given door"; inputSchema = z.object({ phone: z.string().describe("WhatsApp phone number (E.164 format)"), door: z.string().default("front").describe("Door name (front, garage, etc.)") }); async execute(input: z.infer) { const now = Date.now(); const buildingId = env('BUILDING_ID') || 'default'; // Query guests with valid access const result = await Data.get('guests', { phone: input.phone, buildingId, status: 'ACTIVE', startAt: { $lte: now }, endAt: { $gte: now }, doors: { $in: [input.door] } }); const allowed = result.data.length > 0; const guest = allowed ? result.data[0] : undefined; return { allowed, guestId: guest?.id, name: guest?.data?.name, door: input.door, validUntil: guest?.data?.endAt ? new Date(guest.data.endAt).toLocaleString() : null }; } } ``` #### src/tools/UnlockDoorTool.ts ```typescript theme={null} import { LuaTool, Data, env } from 'lua-cli'; import { z } from 'zod'; export default class UnlockDoorTool implements LuaTool { name = "unlock_door"; description = "Pulse the relay to unlock a door for a few seconds"; inputSchema = z.object({ door: z.string().default("front").describe("Door name to unlock"), ms: z.number().int().min(500).max(10000) .default(parseInt(env('DEFAULT_UNLOCK_MS') || '3000')) .describe("Unlock duration in milliseconds") }); async execute(input: z.infer) { const base = env('PI_BASE_URL'); const key = env('PI_API_KEY'); if (!base || !key) { throw new Error('PI_BASE_URL or PI_API_KEY not configured'); } // Map door name to GPIO pin const doorMap = JSON.parse(env('DOOR_MAP_JSON') || '{"front":17}'); const pin = doorMap[input.door]; if (pin === undefined) { throw new Error(`Unknown door: ${input.door}`); } // Call Pi Edge API const res = await fetch(`${base}/door/unlock`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': key }, body: JSON.stringify({ pin, ms: input.ms, active_low: env('ACTIVE_LOW') === 'true' }) }); if (!res.ok) { const error = await res.text(); throw new Error(`Edge API error: ${res.status} ${error}`); } const result = await res.json(); // Log the unlock event await Data.create('door_logs', { door: input.door, pin: result.pin, timestamp: new Date().toISOString(), duration_ms: result.ms, success: result.ok }, `door unlock ${input.door}`); return { success: true, door: input.door, duration: `${input.ms}ms`, message: `Door unlocked. It will re-lock in ~${Math.round(input.ms/1000)}s.` }; } } ``` #### src/tools/RegisterGuestTool.ts ```typescript theme={null} import { LuaTool, Data, env } from 'lua-cli'; import { z } from 'zod'; export default class RegisterGuestTool implements LuaTool { name = "register_guest"; description = "Create or extend a guest's access window (staff only)"; inputSchema = z.object({ phone: z.string().describe("Guest phone number (E.164)"), name: z.string().optional().describe("Guest name"), buildingId: z.string().default(env('BUILDING_ID') || 'default'), doors: z.array(z.string()).describe("Doors guest can access"), startAt: z.number().describe("Access start time (epoch ms)"), endAt: z.number().describe("Access end time (epoch ms)"), status: z.enum(['ACTIVE', 'REVOKED']).default('ACTIVE') }); async execute(input: z.infer) { // Validate time window if (input.startAt >= input.endAt) { throw new Error('Start time must be before end time'); } const entry = await Data.create('guests', { phone: input.phone, name: input.name, buildingId: input.buildingId, doors: input.doors, startAt: input.startAt, endAt: input.endAt, status: input.status, registeredAt: new Date().toISOString() }, `${input.name || input.phone} ${input.buildingId} ${input.doors.join(',')}`); return { success: true, guestId: entry.id, name: input.name, phone: input.phone, doors: input.doors, validFrom: new Date(input.startAt).toLocaleString(), validUntil: new Date(input.endAt).toLocaleString(), message: `Guest access registered for ${input.doors.join(', ')}` }; } } ``` #### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, PreProcessor } from 'lua-cli'; import CheckAccessTool from './tools/CheckAccessTool'; import UnlockDoorTool from './tools/UnlockDoorTool'; import RegisterGuestTool from './tools/RegisterGuestTool'; // Door control skill const doorSkill = new LuaSkill({ name: "door-control", description: "Secure door access control via Raspberry Pi", context: ` This skill controls building door access with strict security. Access Control Flow: 1. check_access: ALWAYS check first - verify phone has valid access 2. unlock_door: Only if check_access returns allowed=true 3. register_guest: Staff only - add or extend guest access Security Rules: - NEVER unlock without verifying access first - Check time windows (startAt/endAt) - Rate-limit repeated unlock attempts - Log all unlock events - Confirm which door before unlocking User Experience: - Ask which door if unclear ("front" or "garage") - Report unlock duration in response - Provide helpful error messages for denied access - Suggest contacting staff if no valid booking `, tools: [ new CheckAccessTool(), new UnlockDoorTool(), new RegisterGuestTool() ] }); // Security preprocessor: Rate limiting const rateLimitPreProcessor = new PreProcessor({ name: 'unlock-rate-limit', description: 'Prevent rapid repeated unlock attempts', priority: 1, execute: async (message, user) => { const text = message.content.toLowerCase(); // Check if message is about unlocking if (text.includes('unlock') || text.includes('open door')) { // Check recent unlock requests const recentUnlocks = await Data.search('door_logs', user.id, 10); const lastMinute = recentUnlocks.filter(log => { const logTime = new Date(log.timestamp).getTime(); return Date.now() - logTime < 60000; // Last minute }); if (lastMinute.length >= 3) { return { block: true, response: "You've made multiple unlock requests recently. Please wait a moment before trying again." }; } } return { block: false }; } }); // Configure agent export const agent = new LuaAgent({ name: "door-assistant", persona: `You are a concise, security-focused door access assistant. Your role: - Verify guest access permissions before unlocking - Control door locks securely and safely - Maintain audit logs of all access - Provide clear feedback on access status Security Protocol: 1. Identify user by WhatsApp phone number 2. If user asks to unlock/open door, ask which door if unclear 3. ALWAYS call check_access first to verify permissions 4. If allowed: call unlock_door and confirm success 5. If denied: politely explain and suggest contacting staff 6. Log every unlock attempt Communication style: - Concise and professional - Security-conscious - Clear about access status - Helpful with denied requests Safety rules: - NEVER unlock without valid access check - NEVER reveal GPIO pin numbers or internal config - Rate-limit rapid repeated requests - Confirm door name before unlocking - Report unlock duration in confirmation Typical responses: - Allowed: "✅ Front door unlocked. It will re-lock in 3 seconds." - Denied: "I don't have an active booking for your number. Please contact the front desk or provide your booking details." - Unclear: "Which door would you like to open? Front or garage?" When to escalate: - Guest has no valid booking - Access outside time window - Technical errors with lock - Multiple failed attempts`, skills: [doorSkill], preProcessors: [rateLimitPreProcessor] }); ``` Uses LuaAgent with preprocessors for rate limiting and security validation. *** ### 2. WhatsApp Channel Setup ```bash theme={null} lua push && lua deploy ``` ```bash theme={null} lua channels ``` Select WhatsApp and provide: * Phone Number ID (from Meta Business Suite) * WhatsApp Business Account ID * Access Token Copy the webhook URL provided by Lua CLI In Meta Business Suite → WhatsApp → Configuration: * Add webhook URL * Subscribe to `messages` events * Verify webhook Send a WhatsApp message to your business number: * "Open front door" Complete WhatsApp channel setup instructions *** ## Guest Management ### Register a Guest (Staff Operation) Use `lua test` to register guests: ```bash theme={null} lua test # Select: register_guest ``` **Example inputs:** * Phone: +14155551234 * Name: John Doe * Building ID: hotel-abc * Doors: \["front", "garage"] * Start: 1735660800000 (epoch ms for check-in) * End: 1735833600000 (epoch ms for check-out) * Status: ACTIVE Or create via Data API directly: ```typescript theme={null} await Data.create('guests', { phone: '+14155551234', name: 'John Doe', buildingId: 'hotel-abc', doors: ['front', 'garage'], startAt: Date.now(), endAt: Date.now() + (48 * 3600000), // 48 hours status: 'ACTIVE' }, 'John Doe hotel-abc front,garage'); ``` *** ## Security Best Practices **Never unlock without checking permissions** ```typescript theme={null} // ALWAYS do this: const access = await checkAccess(phone, door); if (!access.allowed) { return { error: 'Access denied' }; } await unlockDoor(door); ``` **Check current time is within access window** Guests table includes `startAt` and `endAt` epoch timestamps. Query ensures current time is within window. **Prevent abuse with rate limits** * Server-side: 2-second minimum between unlocks * PreProcessor: Max 3 unlock requests per minute * Audit log: Track all attempts **Log every unlock attempt** ```typescript theme={null} await Data.create('door_logs', { door: input.door, phone: user.phone, timestamp: new Date().toISOString(), success: true, guestId: access.guestId }); ``` **Electrical safety is critical** * Use separate 12V PSU for lock (never from Pi) * Optocoupled relay for isolation * Flyback diode across lock coil * Keep unlock pulses short (2-5s) * Fail-secure locks (locked when unpowered) *** ## Alternative: Twilio WhatsApp Webhook If using Twilio instead of Meta's WhatsApp Business API: ### src/webhooks/twilio-whatsapp.ts ```typescript theme={null} import { LuaWebhook, env } from 'lua-cli'; import crypto from 'crypto'; function validateTwilioSignature(url: string, params: Record, signature: string, authToken: string): boolean { const data = Object.keys(params).sort().reduce((acc, k) => acc + k + params[k], url); const digest = crypto.createHmac('sha1', authToken).update(data).digest('base64'); return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature)); } const twilioWebhook = new LuaWebhook({ name: 'twilio-whatsapp', description: 'Handle Twilio WhatsApp inbound messages', verifySignature: false, // Custom validation below execute: async (event) => { const authToken = env('TWILIO_AUTH_TOKEN'); const sig = event.headers['x-twilio-signature']; const webhookUrl = env('TWILIO_WEBHOOK_PUBLIC_URL'); if (!authToken || !sig || !webhookUrl) { return { ok: false, reason: 'missing-config' }; } const params = Object.fromEntries(new URLSearchParams(event.body)); if (!validateTwilioSignature(webhookUrl, params, sig, authToken)) { return { ok: false, reason: 'invalid-signature' }; } const from = params.WaId || params.From || ''; const text = params.Body?.trim() || ''; // Route to agent for processing // Lua handles message routing automatically return { ok: true, from, text }; } }); export default twilioWebhook; ``` Add to your agent: ```typescript theme={null} import twilioWebhook from './webhooks/twilio-whatsapp'; export const agent = new LuaAgent({ // ... other config webhooks: [twilioWebhook] }); ``` *** ## Conversation Flows ### Guest with Valid Access ``` Guest via WhatsApp: "Open front door" Agent: Checking access... Agent: ✅ Front door unlocked. It will re-lock in 3 seconds. ``` ### Guest without Access ``` Guest via WhatsApp: "Open door" Agent: I don't have an active booking for your number. Please contact the front desk with your booking confirmation, or provide your booking name and dates. ``` ### Staff Registering Guest ``` Staff via lua test: register_guest → Phone: +14155551234 → Name: John Doe → Doors: ["front", "garage"] → Start: [check-in timestamp] → End: [check-out timestamp] Agent: ✅ Guest access registered for front, garage ``` *** ## Production Deployment ### Run Edge API on Boot Create `/etc/systemd/system/door-edge.service`: ```ini theme={null} [Unit] Description=Door Control Edge API After=network-online.target Wants=network-online.target [Service] User=pi WorkingDirectory=/home/pi/door-edge Environment=EDGE_API_KEY=supersecret Environment=DOOR_PIN=17 Environment=ACTIVE_LOW=true Environment=UNLOCK_MS=3000 ExecStart=/home/pi/door-edge/.venv/bin/python edge_api.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` Enable: ```bash theme={null} sudo systemctl daemon-reload sudo systemctl enable --now door-edge ``` ### Set Static IP for Pi ```bash theme={null} # Edit /etc/dhcpcd.conf sudo nano /etc/dhcpcd.conf # Add: interface eth0 static ip_address=192.168.1.100/24 static routers=192.168.1.1 static domain_name_servers=192.168.1.1 ``` Update `PI_BASE_URL` to use the static IP. *** ## Monitoring & Audit ### View Unlock Logs ```typescript theme={null} // Query recent unlocks const logs = await Data.get('door_logs', {}, 1, 100); logs.data.forEach(log => { console.log(`${log.data.timestamp}: ${log.data.door} - ${log.data.success ? 'Success' : 'Failed'}`); }); ``` ### View Active Guests ```typescript theme={null} const now = Date.now(); const activeGuests = await Data.get('guests', { status: 'ACTIVE', startAt: { $lte: now }, endAt: { $gte: now } }); console.log(`${activeGuests.data.length} active guests`); ``` *** ## Key Features Guests unlock doors via WhatsApp messages Check-in/check-out time validation Control multiple doors with different pins Complete logs of all unlock attempts Prevent abuse with preprocessor filtering Register and manage guest access *** ## WhatsApp Best Practices **WhatsApp Rules:** * Users must message you first (opt-in required) * 24-hour conversation window applies * Use message templates for notifications outside window * Respect Meta's Business messaging policies See complete WhatsApp guidelines: [WhatsApp Channel Guide](/channels/whatsapp) *** ## Use Cases **Guest Room Access** * Check-in: Register guest with room access * Guest WhatsApps: "Open room 305" * Check-out: Access automatically expires **Multi-property:** Use different `buildingId` per location **Tenant Access** * Move-in: Register tenant with building access * Tenant WhatsApps: "Open front door" * Guest access: Temporary access for visitors **Multiple doors:** Front entrance, garage, amenities **Member Access** * Membership: Register with access times (9 AM - 6 PM) * Member WhatsApps: "Open office" * After-hours: Denied with message about hours **Tiered access:** Different doors for different plans **Guest Booking** * Booking confirmed: Register guest with dates * Guest arrives: WhatsApp to unlock * Check-out: Access expires automatically **Contactless:** Fully automated check-in *** ## Troubleshooting **Check:** * Edge API is running: `curl http://raspberrypi.local:5001/health` * GPIO permissions: User in gpio group, logged back in * Relay wiring: Correct pins, proper power supply * Active-low setting: Try toggling `ACTIVE_LOW` **Check:** * Guest is in database: `Data.get('guests')` * Time window is current: startAt \< now \< endAt * Status is ACTIVE * Building ID matches * Phone number format matches (E.164) **Check:** * Agent deployed: `lua deploy` * WhatsApp channel connected: `lua channels` * Webhook verified in Meta Business Suite * Check logs: `lua logs` **Adjust:** * Increase time window in PreProcessor (60000ms → 120000ms) * Increase max attempts (3 → 5) * Adjust server-side throttle in edge\_api.py (2s → 5s) *** ## Next Steps See all Raspberry Pi examples Complete WhatsApp channel guide Learn about guest data management Learn about rate limiting and filtering # IoT Greenhouse Climate Monitor Source: https://docs.heylua.ai/demos/iot-greenhouse Read temperature, humidity, and pressure from BME280 sensor on Raspberry Pi ## Overview Monitor environmental conditions in your greenhouse, grow room, or any space using a **BME280 sensor** connected to a Raspberry Pi. Get real-time readings via chat and set up automated alerts for out-of-range conditions. **What it does:** * Read temperature, humidity, and pressure * Get climate updates via chat * Automated alerts for threshold violations * Historical data tracking **Hardware:** Raspberry Pi 4/5, BME280 sensor (I²C) **APIs used:** Custom Edge API + Lua Data API (for history) *** ## Architecture ``` User Chat → Lua Agent → ReadClimateTool → Edge API → BME280 → Environmental Data ``` *** ## Complete Implementation ### Edge API on Raspberry Pi #### Setup (one-time) ```bash theme={null} # Install OS packages sudo apt update sudo apt install -y python3-pip python3-venv i2c-tools # Enable I2C interface sudo raspi-config nonint do_i2c 0 # Or use raspi-config TUI: Interface Options → I2C → Enable # Create project folder mkdir -p ~/iot-edge && cd ~/iot-edge python3 -m venv .venv source .venv/bin/activate # Install dependencies pip install flask adafruit-blinka adafruit-circuitpython-bme280 ``` #### Verify I2C Connection ```bash theme={null} # Check if BME280 is detected (should show 0x76 or 0x77) i2cdetect -y 1 ``` #### Edge API Code Update `edge_api.py` (or add to existing): ```python theme={null} from flask import Flask, request, jsonify from functools import wraps import os, time try: import board, busio from adafruit_bme280 import basic as adafruit_bme280 _i2c = busio.I2C(board.SCL, board.SDA) _bme280 = adafruit_bme280.Adafruit_BME280_I2C(_i2c) except Exception as e: _bme280 = None print(f"BME280 not available: {e}") app = Flask(__name__) API_KEY = os.environ.get("EDGE_API_KEY", "changeme") def require_key(fn): @wraps(fn) def wrapper(*args, **kwargs): if request.headers.get("X-API-Key") != API_KEY: return jsonify({"error": "unauthorized"}), 401 return fn(*args, **kwargs) return wrapper @app.get("/health") def health(): return {"ok": True, "ts": int(time.time())} @app.get("/sensors/env") @require_key def sensors_env(): if not _bme280: return jsonify({"error": "BME280 not available"}), 400 return { "temperature_c": round(_bme280.temperature, 2), "temperature_f": round(_bme280.temperature * 9/5 + 32, 2), "humidity": round(_bme280.humidity, 1), "pressure_hpa": round(_bme280.pressure, 1), "timestamp": int(time.time()) } if __name__ == "__main__": app.run(host="0.0.0.0", port=5001) ``` #### Run Edge API ```bash theme={null} export EDGE_API_KEY="supersecret" python edge_api.py ``` #### Test Edge API ```bash theme={null} curl -H "X-API-Key: supersecret" http://raspberrypi.local:5001/sensors/env ``` *** ### Lua Agent Implementation #### src/tools/ReadClimateTool.ts ```typescript theme={null} import { LuaTool, Data, env } from 'lua-cli'; import { z } from 'zod'; export default class ReadClimateTool implements LuaTool { name = "read_climate"; description = "Read temperature, humidity, and pressure from BME280 sensor"; inputSchema = z.object({ storeHistory: z.boolean().default(true).describe("Save reading to history") }); async execute(input: z.infer) { const base = env('PI_BASE_URL'); const key = env('PI_API_KEY'); if (!base || !key) { throw new Error('PI_BASE_URL or PI_API_KEY not configured'); } const res = await fetch(`${base}/sensors/env`, { headers: { 'X-API-Key': key } }); if (!res.ok) { throw new Error(`Edge API error: ${res.status} ${await res.text()}`); } const data = await res.json(); // Store in history if requested if (input.storeHistory) { await Data.create('climate_readings', { temperature_c: data.temperature_c, temperature_f: data.temperature_f, humidity: data.humidity, pressure_hpa: data.pressure_hpa, timestamp: new Date().toISOString() }, `climate ${data.temperature_c}C ${data.humidity}% ${data.pressure_hpa}hPa`); } return { temperature: `${data.temperature_c}°C (${data.temperature_f}°F)`, humidity: `${data.humidity}%`, pressure: `${data.pressure_hpa} hPa`, timestamp: new Date(data.timestamp * 1000).toLocaleString(), status: this.evaluateConditions(data) }; } private evaluateConditions(data: any): string { const alerts = []; if (data.temperature_c > 30) alerts.push("⚠️ High temperature"); if (data.temperature_c < 15) alerts.push("❄️ Low temperature"); if (data.humidity > 80) alerts.push("💧 High humidity"); if (data.humidity < 30) alerts.push("🏜️ Low humidity"); return alerts.length > 0 ? alerts.join(', ') : '✅ All conditions normal'; } } ``` #### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaJob } from 'lua-cli'; import ReadClimateTool from './tools/ReadClimateTool'; // Climate monitoring skill const climateSkill = new LuaSkill({ name: "greenhouse-climate", description: "Monitor greenhouse environmental conditions", context: ` This skill monitors temperature, humidity, and pressure via BME280 sensor. - read_climate: Get current environmental readings Use when user asks about temperature, humidity, pressure, or conditions Always mention if conditions are outside normal range. Suggest actions for out-of-range conditions. `, tools: [new ReadClimateTool()] }); // Hourly climate check job const hourlyClimateCheckJob = new LuaJob({ name: 'hourly-climate-check', description: 'Check climate conditions every hour and alert if out of range', schedule: { type: 'interval', intervalSeconds: 3600 // Every hour }, execute: async (job) => { const tool = new ReadClimateTool(); const reading = await tool.execute({ storeHistory: true }); // Alert if conditions are abnormal if (reading.status !== '✅ All conditions normal') { const user = await job.user(); await user.send([{ type: 'text', text: `🌡️ Climate Alert:\n\n${reading.temperature}\n${reading.humidity}\n${reading.pressure}\n\n${reading.status}` }]); } } }); // Configure agent export const agent = new LuaAgent({ name: "greenhouse-monitor", persona: `You are a greenhouse climate monitoring assistant. Your role: - Monitor temperature, humidity, and pressure - Alert when conditions are out of range - Provide climate recommendations - Track environmental history Communication style: - Clear and data-driven - Proactive with alerts - Helpful with recommendations Climate knowledge: - Ideal temp: 18-28°C (64-82°F) - Ideal humidity: 50-70% - Normal pressure: 980-1020 hPa Recommendations: - High temp: Increase ventilation - Low temp: Close vents, add heat - High humidity: Increase air circulation - Low humidity: Add water trays or misting When to alert: - Temperature outside 15-30°C - Humidity outside 30-80% - Rapid changes (>5°C/hour)`, skills: [climateSkill], jobs: [hourlyClimateCheckJob] }); ``` Uses LuaAgent with scheduled jobs for hourly climate monitoring and automated alerts. *** ## Environment Setup ```bash theme={null} # .env PI_BASE_URL=http://raspberrypi.local:5001 PI_API_KEY=supersecret ``` *** ## Wiring BME280 **I²C Connection:** ``` Raspberry Pi BME280 ----------- ------- 3.3V ────────> VIN GND ────────> GND GPIO 2 (SDA)────────> SDA GPIO 3 (SCL)────────> SCL ``` **I²C Address:** BME280 typically uses 0x76 or 0x77. The Adafruit library auto-detects the address. *** ## Testing ```bash theme={null} # Test tool directly lua test # Select: read_climate # Test conversationally lua chat # You: "What's the temperature and humidity?" # You: "Check the greenhouse conditions" # You: "Is it too hot in there?" ``` *** ## Key Features Instant environmental readings via chat Hourly checks with out-of-range notifications Store readings in Lua Data for trend analysis AI suggests actions based on conditions *** ## Customization Ideas ### Add Alert Thresholds ```typescript theme={null} inputSchema = z.object({ storeHistory: z.boolean().default(true), alertIfTempAbove: z.number().optional(), alertIfHumidityAbove: z.number().optional() }); ``` ### Daily Summary Report ```typescript theme={null} const dailySummaryJob = new LuaJob({ name: 'daily-climate-summary', schedule: { type: 'cron', pattern: '0 8 * * *' // 8 AM daily }, execute: async (job) => { // Get last 24 hours of readings const readings = await Data.get('climate_readings', 24); const avgTemp = readings.reduce((sum, r) => sum + r.data.temperature_c, 0) / readings.length; const avgHumidity = readings.reduce((sum, r) => sum + r.data.humidity, 0) / readings.length; const user = await job.user(); await user.send([{ type: 'text', text: `📊 24-Hour Climate Summary:\n\nAvg Temp: ${avgTemp.toFixed(1)}°C\nAvg Humidity: ${avgHumidity.toFixed(1)}%\nReadings: ${readings.length}` }]); } }); ``` *** ## Next IoT Demo Capture photos on-demand with Raspberry Pi Camera # IoT Smart Light Control Source: https://docs.heylua.ai/demos/iot-smart-light Control GPIO relay/LED on Raspberry Pi via chat ## Overview Control lights, relays, or any GPIO-connected devices on a **Raspberry Pi** through natural language conversation. Turn devices on/off, check status, and automate with scheduled jobs. **What it does:** * Toggle relay or LED on/off via chat * Control GPIO pins remotely * Schedule automated on/off times * Safe, authenticated edge API **Hardware:** Raspberry Pi 4/5, relay module (optocoupled recommended), jumper wires **APIs used:** Custom Edge API on Raspberry Pi *** ## Architecture ``` User Chat → Lua Agent → SetRelayTool → Edge API (Flask) → GPIO → Relay → Physical Device ``` *** ## Complete Implementation ### Edge API on Raspberry Pi #### Setup (one-time) ```bash theme={null} # Install OS packages sudo apt update sudo apt install -y python3-pip python3-venv python3-libgpiod # Add user to gpio group (required for GPIO access without sudo) sudo adduser $USER gpio # Log out and back in (or reboot) for this to take effect # Create project folder mkdir -p ~/iot-edge && cd ~/iot-edge python3 -m venv .venv source .venv/bin/activate # Install dependencies pip install flask gpiozero ``` **Important:** After adding your user to the gpio group, you must log out and back in (or reboot) for permissions to take effect. #### Edge API Code Create `edge_api.py`: ```python theme={null} from flask import Flask, request, jsonify from functools import wraps from gpiozero import OutputDevice import os, time app = Flask(__name__) API_KEY = os.environ.get("EDGE_API_KEY", "changeme") def require_key(fn): @wraps(fn) def wrapper(*args, **kwargs): if request.headers.get("X-API-Key") != API_KEY: return jsonify({"error": "unauthorized"}), 401 return fn(*args, **kwargs) return wrapper @app.get("/health") def health(): return {"ok": True, "ts": int(time.time())} @app.post("/gpio/relay") @require_key def gpio_relay(): data = request.get_json(force=True) pin = int(data.get("pin", 17)) # BCM pin number state = str(data.get("state", "off")).lower() active_low = bool(data.get("active_low", True)) # many relay boards are active-low dev = OutputDevice(pin, active_high=not active_low, initial_value=False) if state == "on": dev.on() else: dev.off() return {"pin": pin, "state": state, "active_low": active_low} if __name__ == "__main__": app.run(host="0.0.0.0", port=5001) ``` #### Run the Edge API ```bash theme={null} export EDGE_API_KEY="supersecret" python edge_api.py ``` #### Test Edge API ```bash theme={null} # Health check curl http://raspberrypi.local:5001/health # Turn relay on curl -X POST http://raspberrypi.local:5001/gpio/relay \ -H "X-API-Key: supersecret" \ -H "Content-Type: application/json" \ -d '{"pin":17,"state":"on","active_low":true}' ``` *** ### Lua Agent Implementation #### src/tools/SetRelayTool.ts ```typescript theme={null} import { LuaTool, env } from 'lua-cli'; import { z } from 'zod'; export default class SetRelayTool implements LuaTool { name = "set_relay"; description = "Turn a GPIO relay or LED on/off on the Raspberry Pi"; inputSchema = z.object({ pin: z.number().int().default(17).describe("BCM GPIO pin number"), state: z.enum(["on","off"]).describe("Turn device on or off"), active_low: z.boolean().default(true).describe("Many relay boards are active-low") }); async execute(input: z.infer) { const base = env('PI_BASE_URL'); const key = env('PI_API_KEY'); if (!base || !key) { throw new Error('PI_BASE_URL or PI_API_KEY not configured'); } const res = await fetch(`${base}/gpio/relay`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': key }, body: JSON.stringify(input) }); if (!res.ok) { throw new Error(`Edge API error: ${res.status} ${await res.text()}`); } const result = await res.json(); return { success: true, pin: result.pin, state: result.state, message: `GPIO pin ${result.pin} turned ${result.state}` }; } } ``` #### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill, LuaJob } from 'lua-cli'; import SetRelayTool from './tools/SetRelayTool'; // IoT control skill const iotSkill = new LuaSkill({ name: "raspberry-pi-gpio", description: "Control GPIO devices on Raspberry Pi", context: ` This skill controls physical devices on a Raspberry Pi. - set_relay: Turn GPIO relay or LED on/off Use when user asks to control lights, fans, or any GPIO device Safety: - Always confirm which device a pin controls before toggling - Never rapidly toggle relays without user intent - Mention current state after changing `, tools: [new SetRelayTool()] }); // Scheduled job: Turn off grow light at night const nighttimeOffJob = new LuaJob({ name: 'nighttime-off', description: 'Turn off grow light at 10 PM', schedule: { type: 'cron', pattern: '0 22 * * *' // 10 PM daily }, execute: async (job) => { const tool = new SetRelayTool(); await tool.execute({ pin: 17, state: 'off', active_low: true }); const user = await job.user(); await user.send([{ type: 'text', text: '🌙 Grow light turned off for the night.' }]); } }); // Configure agent export const agent = new LuaAgent({ name: "smart-home-agent", persona: `You are a smart home automation assistant controlling Raspberry Pi devices. Your role: - Control lights, fans, and GPIO devices - Confirm actions before making physical changes - Report current device states - Provide clear feedback on actions Communication style: - Clear and confirmatory - Safety-conscious - Brief and actionable Safety practices: - Always confirm which device you're controlling - Mention current state after changes - Don't rapidly toggle devices - Warn if unsure about pin assignments Device knowledge: - Pin 17: Grow light (active-low relay) - Pin 18: Ventilation fan - Pin 27: LED indicator`, skills: [iotSkill], jobs: [nighttimeOffJob] }); ``` Uses LuaAgent with scheduled jobs for automated device control (e.g., turn off lights at night). *** ## Environment Setup ```bash theme={null} # .env PI_BASE_URL=http://raspberrypi.local:5001 PI_API_KEY=supersecret ``` Or set via CLI: ```bash theme={null} lua env sandbox # Add: PI_BASE_URL and PI_API_KEY ``` *** ## Testing ### Test Edge API Directly ```bash theme={null} # Turn relay on curl -X POST http://raspberrypi.local:5001/gpio/relay \ -H "X-API-Key: supersecret" \ -H "Content-Type: application/json" \ -d '{"pin":17,"state":"on","active_low":true}' ``` ### Test with Lua ```bash theme={null} # Test tool directly lua test # Select: set_relay # Input: pin=17, state=on # Test conversationally lua chat # You: "Turn on the grow light" # You: "Turn off pin 17" ``` *** ## Wiring **Relay Module (Active-Low):** ``` Raspberry Pi Relay Module ----------- ------------- 3.3V ────────> VCC GND ────────> GND GPIO 17 ────────> IN (Signal) Relay Output ─────> Your Device (Light/Fan) ``` **Safety:** Use optocoupled relay modules for AC loads. Never exceed the relay's rated voltage/current. Always verify correct wiring before powering on. *** ## Production Deployment ### Run Edge API on Boot Create systemd service `/etc/systemd/system/iot-edge.service`: ```ini theme={null} [Unit] Description=IoT Edge API After=network-online.target [Service] User=pi WorkingDirectory=/home/pi/iot-edge Environment=EDGE_API_KEY=supersecret ExecStart=/home/pi/iot-edge/.venv/bin/flask --app edge_api run --host=0.0.0.0 --port=5001 Restart=always [Install] WantedBy=multi-user.target ``` Enable and start: ```bash theme={null} sudo systemctl daemon-reload sudo systemctl enable --now iot-edge ``` *** ## Key Features Control GPIO devices from anywhere via chat API key authentication protects your edge API Jobs for automated on/off schedules Confirmation before physical state changes *** ## Next IoT Demo Read temperature, humidity, and pressure from BME280 sensor # Logistics & Shipping Tracker Source: https://docs.heylua.ai/demos/logistics-tracker Package tracking with UPS, FedEx, and USPS APIs ## Overview Logistics operations assistant integrating with **multiple shipping carrier APIs** (UPS, FedEx, USPS) for unified package tracking. **What it does:** * Track packages across all carriers * Get delivery estimates * Schedule pickups * Compare shipping rates * Handle delivery exceptions **APIs used:** UPS API, FedEx API, USPS API (all external) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { TrackPackageTool, CompareRatesTool, SchedulePickupTool } from "./tools/LogisticsTools"; // Logistics operations skill const logisticsSkill = new LuaSkill({ name: "logistics-operations", description: "Multi-carrier shipping and tracking operations", context: ` This skill helps track shipments across multiple carriers. - track_package: Track package across UPS, FedEx, or USPS - compare_rates: Compare shipping rates between carriers - schedule_pickup: Schedule package pickup Auto-detect carrier from tracking number when possible. Provide detailed tracking history. Alert for delivery exceptions. `, tools: [ new TrackPackageTool(), new CompareRatesTool(), new SchedulePickupTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "logistics-operations-assistant", persona: `You are an efficient logistics operations specialist. Your role: - Track packages across multiple carriers - Provide shipping status updates - Compare carrier rates - Schedule pickups - Handle delivery exceptions Communication style: - Clear and concise - Proactive with updates - Detail-oriented - Responsive to urgent requests Best practices: - Auto-detect carrier from tracking numbers - Provide estimated delivery dates - Alert for delivery exceptions or delays - Offer alternative carriers for better rates - Include tracking history details - Suggest optimal shipping methods Carrier knowledge: - UPS: Express, Ground, International - FedEx: Overnight, 2-Day, Ground - USPS: Priority, First-Class, Media Mail - Delivery timeframes and coverage When to escalate: - Lost or damaged packages - International customs issues - High-value shipments - Complex routing requirements`, skills: [logisticsSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills. ### src/tools/LogisticsTools.ts ```typescript theme={null} import { LuaTool, env } from "lua-cli"; import { z } from "zod"; // 1. Track Package (Multi-Carrier) export class TrackPackageTool implements LuaTool { name = "track_package"; description = "Track package across UPS, FedEx, or USPS"; inputSchema = z.object({ trackingNumber: z.string(), carrier: z.enum(['ups', 'fedex', 'usps', 'auto']).default('auto') }); async execute(input: z.infer) { // Auto-detect carrier if not specified const carrier = input.carrier === 'auto' ? this.detectCarrier(input.trackingNumber) : input.carrier; switch (carrier) { case 'ups': return await this.trackUPS(input.trackingNumber); case 'fedex': return await this.trackFedEx(input.trackingNumber); case 'usps': return await this.trackUSPS(input.trackingNumber); default: throw new Error('Unable to detect carrier'); } } private detectCarrier(trackingNumber: string): string { // UPS: 1Z followed by 16 alphanumeric if (/^1Z[0-9A-Z]{16}$/i.test(trackingNumber)) return 'ups'; // FedEx: 12 or 14 digits if (/^\d{12}$|^\d{14}$/.test(trackingNumber)) return 'fedex'; // USPS: 20-22 digits if (/^\d{20,22}$/.test(trackingNumber)) return 'usps'; return 'auto'; } private async trackUPS(trackingNumber: string) { const upsKey = env('UPS_API_KEY'); const response = await fetch('https://onlinetools.ups.com/api/track/v1/details/' + trackingNumber, { headers: { 'Authorization': `Bearer ${upsKey}`, 'Accept': 'application/json' } }); const data = await response.json(); const shipment = data.trackResponse.shipment[0]; return { carrier: 'UPS', trackingNumber, status: shipment.package[0].currentStatus.description, location: shipment.package[0].currentStatus.location, estimatedDelivery: shipment.package[0].deliveryDate[0].date, events: shipment.package[0].activity.map(a => ({ date: a.date, time: a.time, status: a.status.description, location: a.location?.address?.city })) }; } private async trackFedEx(trackingNumber: string) { const fedexKey = env('FEDEX_API_KEY'); const response = await fetch('https://apis.fedex.com/track/v1/trackingnumbers', { method: 'POST', headers: { 'Authorization': `Bearer ${fedexKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ trackingInfo: [{ trackingNumberInfo: { trackingNumber } }], includeDetailedScans: true }) }); const data = await response.json(); const trackInfo = data.output.completeTrackResults[0].trackResults[0]; return { carrier: 'FedEx', trackingNumber, status: trackInfo.latestStatusDetail.description, location: trackInfo.latestStatusDetail.scanLocation?.city, estimatedDelivery: trackInfo.estimatedDeliveryTime, events: trackInfo.scanEvents.map(e => ({ date: e.date, status: e.eventDescription, location: e.scanLocation?.city })) }; } private async trackUSPS(trackingNumber: string) { const uspsKey = env('USPS_USER_ID'); const xml = ``; const response = await fetch( `https://secure.shippingapis.com/ShippingAPI.dll?API=TrackV2&XML=${encodeURIComponent(xml)}` ); const text = await response.text(); // Parse XML response const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/xml'); return { carrier: 'USPS', trackingNumber, status: doc.querySelector('Status')?.textContent, location: doc.querySelector('EventCity')?.textContent, estimatedDelivery: doc.querySelector('ExpectedDeliveryDate')?.textContent }; } } // 2. Compare Shipping Rates export class CompareRatesTool implements LuaTool { name = "compare_shipping_rates"; description = "Compare shipping rates across carriers"; inputSchema = z.object({ originZip: z.string(), destinationZip: z.string(), weight: z.number().describe("Weight in pounds"), dimensions: z.object({ length: z.number(), width: z.number(), height: z.number() }) }); async execute(input: z.infer) { // Get rates from all carriers const upsRate = await this.getUPSRate(input); const fedexRate = await this.getFedExRate(input); const uspsRate = await this.getUSPSRate(input); const rates = [upsRate, fedexRate, uspsRate].sort((a, b) => a.cost - b.cost); return { rates: rates.map(r => ({ carrier: r.carrier, service: r.service, cost: `$${r.cost.toFixed(2)}`, estimatedDays: r.estimatedDays, delivery: r.estimatedDelivery })), cheapest: rates[0].carrier, fastest: rates.reduce((prev, curr) => curr.estimatedDays < prev.estimatedDays ? curr : prev ).carrier }; } private async getUPSRate(input: any) { // UPS Rating API call return { carrier: 'UPS', service: 'Ground', cost: 12.50, estimatedDays: 5, estimatedDelivery: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] }; } private async getFedExRate(input: any) { // FedEx Rating API call return { carrier: 'FedEx', service: 'Home Delivery', cost: 14.25, estimatedDays: 4, estimatedDelivery: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] }; } private async getUSPSRate(input: any) { // USPS Rating API call return { carrier: 'USPS', service: 'Priority Mail', cost: 9.99, estimatedDays: 3, estimatedDelivery: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] }; } } // 3. Schedule Pickup export class SchedulePickupTool implements LuaTool { name = "schedule_pickup"; description = "Schedule package pickup with carrier"; inputSchema = z.object({ carrier: z.enum(['ups', 'fedex']), pickupDate: z.string(), pickupTime: z.string(), packageCount: z.number(), address: z.object({ street: z.string(), city: z.string(), state: z.string(), zip: z.string() }) }); async execute(input: z.infer) { const apiKey = input.carrier === 'ups' ? env('UPS_API_KEY') : env('FEDEX_API_KEY'); // Schedule pickup with carrier const response = await fetch(`https://${input.carrier}-api.com/pickup/schedule`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ pickup_date: input.pickupDate, ready_time: input.pickupTime, package_count: input.packageCount, pickup_address: input.address }) }); const data = await response.json(); return { success: true, confirmationNumber: data.confirmation_number, carrier: input.carrier.toUpperCase(), pickupDate: input.pickupDate, pickupTime: input.pickupTime, message: `Pickup scheduled with ${input.carrier.toUpperCase()} for ${input.pickupDate}` }; } } ``` ## Environment Setup ```bash theme={null} # .env UPS_API_KEY=your_ups_api_key FEDEX_API_KEY=your_fedex_api_key USPS_USER_ID=your_usps_user_id ``` ## Key Features UPS, FedEx, USPS Detect carrier from tracking# Compare shipping costs Live tracking data # Demo Gallery Source: https://docs.heylua.ai/demos/overview 15 production-ready solutions showcasing Lua's flexibility across business and IoT ## Overview Explore 15 complete, production-ready AI agent implementations across different industries and IoT use cases. Each demo shows full TypeScript code using the **LuaAgent pattern** with external APIs, Lua Platform APIs, and edge computing. Every demo now uses `LuaAgent` for unified configuration, showing how to combine skills, webhooks, jobs, and processors in real-world applications. 6 demos for end-user interactions 5 demos for business operations 4 demos for Raspberry Pi and hardware control All demos use LuaAgent pattern with webhooks, jobs, and processors where applicable Stripe, Zendesk, Plaid, UPS, MLS, BambooHR, and more Products, Baskets, Orders, Data (vector search), Jobs, Templates Custom Flask APIs on Raspberry Pi for GPIO, sensors, and camera Complete code with error handling, validation, and best practices ## Advanced Features in Demos See real-world usage of advanced features across different demos: **Used in: ALL demos** Every demo shows proper agent configuration: ```typescript theme={null} export const agent = new LuaAgent({ name: "demo-agent", persona: "...", skills: [...], webhooks: [...], jobs: [...] }); ``` **Used in: E-commerce, Customer Support, Financial Onboarding** Real webhook integrations: * Stripe payment events * Shopify order notifications * Zendesk ticket updates **Used in: HR, Finance Ops, Customer Support** Scheduled automation: * Daily reports * Abandoned cart reminders * Follow-up messages * Data cleanup **Used in: Healthcare, Financial Services** Message filtering: * Verify patient consent * Validate financial information * Route by urgency **Used in: Healthcare, Financial Services, Legal** Response formatting: * Medical disclaimers * Financial warnings * Compliance footers ## Customer-Facing Agents ### 1. E-commerce Shopping Assistant **Complete shopping experience using Lua Platform APIs** **Features:** Product search, cart management, checkout, order tracking **APIs:** Products, Baskets, Orders (Lua Platform) **Use case:** Online stores, marketplaces ### 2. Customer Support Agent **Support automation with Zendesk + knowledge base** **Features:** Knowledge base search, ticket creation, status tracking **APIs:** Zendesk API (external) + Lua Data (vector search) **Use case:** Help desks, customer service teams ### 3. Financial Services Onboarding **KYC onboarding with document verification** **Features:** Document upload, identity verification, qualifying questions, account creation **APIs:** Stripe Identity API (external) + Lua Data (applications) **Use case:** Banks, fintech, investment platforms ### 4. Hotel Booking Agent **Reservation management with availability checking** **Features:** Room search, bookings, cancellations, room service **APIs:** Lua Data API (rooms, reservations) **Use case:** Hotels, resorts, vacation rentals ### 4. Restaurant Ordering System **Food ordering and table reservations** **Features:** Menu browsing, order placement, reservations **APIs:** Lua Products API (menu) + Data API (orders) **Use case:** Restaurants, cafes, food services ### 5. Real Estate Assistant **Property search with MLS integration** **Features:** Property search, viewings, favorites, comparisons **APIs:** MLS API (external) + Lua Data (cache, favorites) **Use case:** Real estate agencies, property management ## Internal-Facing Agents ### 8. HR Operations Assistant **Employee management with BambooHR integration** **Features:** Employee lookup, time off requests, policy search **APIs:** BambooHR API (external) + Lua Data (policies) **Use case:** HR departments, employee self-service ### 9. Finance Operations Agent **Banking operations with Plaid integration** **Features:** Account balances, transactions, transfers **APIs:** Plaid API (external banking) **Use case:** Finance teams, accounting, treasury ### 10. SaaS Onboarding Assistant **User onboarding with product API integration** **Features:** Account creation, documentation search, usage tracking **APIs:** Your SaaS API (external) + Lua Data (docs) **Use case:** SaaS products, internal tools ### 11. Healthcare Patient Portal **Patient services with EMR integration** **Features:** Appointments, medical records, prescriptions **APIs:** EMR/FHIR API (external) + Lua Data (health info) **Use case:** Healthcare providers, patient portals ### 12. Logistics & Shipping Tracker **Multi-carrier package tracking** **Features:** Track packages (UPS, FedEx, USPS), rate comparison **APIs:** UPS, FedEx, USPS APIs (all external) **Use case:** Logistics, shipping, warehouse operations ## IoT & Edge Computing Agents Control physical hardware and sensors on **Raspberry Pi** through natural language. All demos use a lightweight Flask Edge API running on the Pi. ### 13. Smart Light / GPIO Control **Control GPIO relays and LEDs via chat** **Features:** Turn devices on/off, scheduled automation, safe GPIO control **Hardware:** Raspberry Pi + relay module + gpiozero **Use case:** Home automation, smart lighting, device control ### 14. Greenhouse Climate Monitor **Monitor temperature, humidity, and pressure with BME280** **Features:** Real-time readings, automated alerts, historical tracking **Hardware:** Raspberry Pi + BME280 sensor (I²C) **Use case:** Greenhouses, server rooms, environmental monitoring ### 15. Security Camera Snapshot **Capture photos on-demand with Pi Camera** **Features:** On-demand capture, scheduled snapshots, motion-triggered photos **Hardware:** Raspberry Pi + Camera Module 3/HQ + rpicam-still **Use case:** Security monitoring, time-lapse, remote observation ### 16. Door Access Control **WhatsApp-controlled door locks for hotels and apartments** **Features:** Guest access verification, time-window control, audit logging, WhatsApp integration **Hardware:** Raspberry Pi + relay + 12V electric strike/maglock **Use case:** Hotels, apartments, coworking spaces, vacation rentals ## API Integration Breakdown ### External APIs Used | Demo | External APIs | Purpose | | ---------------- | ---------------- | ---------------------------- | | Customer Support | Zendesk | Ticket management | | Real Estate | MLS | Property listings | | HR Assistant | BambooHR | Employee data | | Finance | Plaid | Banking operations | | SaaS Onboarding | Your API | Product integration | | Healthcare | EMR/FHIR | Medical records | | Logistics | UPS/FedEx/USPS | Shipping tracking | | Smart Light | Edge API (Flask) | GPIO relay control | | Greenhouse | Edge API (Flask) | BME280 sensor readings | | Camera | Edge API (Flask) | Photo capture (rpicam-still) | | Door Assistant | Edge API (Flask) | GPIO relay + access control | ### Lua Platform APIs Used | Demo | Platform APIs | Purpose | | ---------------- | ------------------------- | --------------------------------- | | E-commerce | Products, Baskets, Orders | Shopping workflow | | Customer Support | Data | Knowledge base search | | Hotel Booking | Data | Reservations, rooms | | Restaurant | Products, Data | Menu, orders | | Real Estate | Data | Property cache, favorites | | HR Assistant | Data | Policy search | | SaaS Onboarding | Data | Documentation search | | Healthcare | Data | Health information | | Greenhouse | Data | Climate history tracking | | Camera | Data | Photo metadata logging | | Door Assistant | Data | Guest access control + audit logs | ### Edge APIs Used || Demo | Edge Technology | Hardware | \|------|-----------------|----------| \| Smart Light | Flask + gpiozero | Raspberry Pi + relay module | \| Greenhouse | Flask + BME280 library | Raspberry Pi + BME280 (I²C) | \| Camera | Flask + rpicam-still | Raspberry Pi + Camera Module 3/HQ | \| Door Assistant | Flask + gpiozero | Raspberry Pi + relay + electric strike | ## Integration Patterns Demonstrated **Finance, Logistics** 100% external API integration * Plaid for banking * UPS/FedEx for shipping Shows: Complete custom integration **E-commerce, Hotel, Restaurant** Uses Lua Platform APIs * Products for catalogs * Baskets for carts * Data for storage Shows: Quick development with Platform APIs **Support, Real Estate, HR, SaaS, Healthcare** Mix of both * External for core business logic * Platform for enhanced features Shows: Best of both worlds **IoT Smart Light, Greenhouse, Camera, Door Assistant** Custom Edge APIs on Raspberry Pi * Flask APIs for GPIO, sensors, camera, access control * Local network communication * Hardware control via chat and WhatsApp * Security and audit logging Shows: Physical world integration with AI agents and secure access control ## Learning Paths by Feature Choose demos based on what advanced features you want to learn: **Every demo shows the LuaAgent pattern** * How to configure agent with persona * Adding skills to agent * Welcome message best practices * Complete agent structure **Start with:** E-commerce (simplest) or Customer Support (comprehensive) **Demos with webhook integration:** * **E-commerce Assistant** - Stripe payment webhooks * **Customer Support** - Zendesk ticket webhooks * **Financial Onboarding** - Identity verification webhooks * **Camera (IoT)** - Motion-triggered captures **Learn:** HTTP event handling, signature verification, real-time updates, hardware triggers **Demos with scheduled tasks:** * **HR Assistant** - Daily attendance reports * **Finance Operations** - Weekly reconciliation * **Customer Support** - Follow-up reminders * **Smart Light (IoT)** - Automated on/off schedules * **Greenhouse (IoT)** - Hourly climate monitoring * **Camera (IoT)** - Daily snapshots **Learn:** Cron patterns, scheduled notifications, automated reports, hardware automation **Demos with message filtering:** * **Healthcare Portal** - Patient consent verification * **Financial Onboarding** - Information validation * **Customer Support** - Business hours filtering * **Door Assistant (IoT)** - Rate limiting for security **Learn:** Content filtering, routing, validation, rate limiting, security patterns **Demos with response formatting:** * **Healthcare Portal** - Medical disclaimers * **Financial Onboarding** - Compliance footers * **Customer Support** - Branding and signatures **Learn:** Adding disclaimers, branding, formatting, personalization **Demos with hardware integration:** * **Smart Light** - GPIO control with relays and LEDs * **Greenhouse** - I²C sensor reading (BME280) * **Camera** - Photo capture with rpicam-still * **Door Assistant** - WhatsApp access control with time-window validation **Learn:** Edge APIs, Flask on Pi, gpiozero, hardware control via chat, WhatsApp integration, access control, security patterns ## Code Complexity Levels * **E-commerce**: Platform APIs only * **Hotel Booking**: Lua Data API * **Restaurant**: Products + Data Great for learning Platform APIs and basic LuaAgent configuration * **Customer Support**: Zendesk + vector search + webhooks + jobs * **Real Estate**: MLS + Lua Data * **HR Assistant**: BambooHR + Lua Data + jobs * **SaaS Onboarding**: Your API + Lua Data * **Smart Light (IoT)**: Edge API + GPIO control + jobs * **Greenhouse (IoT)**: Edge API + I²C sensors + jobs * **Camera (IoT)**: Edge API + rpicam-still + jobs + webhooks * **Door Assistant (IoT)**: Edge API + WhatsApp + access control + preprocessors Shows hybrid integration patterns with advanced features and hardware control * **Finance**: Plaid banking integration + jobs * **Healthcare**: FHIR-compliant EMR + pre/post processors * **Logistics**: Multi-carrier APIs * **Financial Onboarding**: Stripe Identity + webhooks + processors Complex external integrations with full advanced feature usage **Every demo shows:** * Proper agent configuration * Persona best practices * Welcome message patterns * Skill organization **You'll see how to:** * Call external APIs (Stripe, Zendesk, etc.) * Use Platform APIs (Products, Data, etc.) * Mix both approaches * Handle authentication **Selected demos show:** * Webhooks for external events * Jobs for scheduled tasks * PreProcessors for validation * PostProcessors for compliance **Best practices:** * Error handling * Input validation * Security patterns * Testing strategies ## Quick Start with Demos Pick one that matches your industry or use case All code is production-ready and copy-paste friendly Add required API keys to `.env` file ```bash theme={null} lua chat # Primary testing lua test # Optional: Specific tools ``` Modify for your specific needs ```bash theme={null} lua push && lua deploy ``` ## All Demos at a Glance | Demo | Type | External APIs | Platform APIs | Complexity | | -------------------- | -------- | --------------------------- | ------------------------- | ---------- | | E-commerce | Customer | None | Products, Baskets, Orders | ⭐ | | Support | Customer | Zendesk | Data | ⭐⭐ | | Financial Onboarding | Customer | Stripe Identity | Data | ⭐⭐⭐ | | Hotel | Customer | None | Data | ⭐ | | Restaurant | Customer | None | Products, Data | ⭐ | | Real Estate | Customer | MLS | Data | ⭐⭐ | | HR | Internal | BambooHR | Data | ⭐⭐ | | Finance Ops | Internal | Plaid | None | ⭐⭐⭐ | | SaaS | Internal | Your API | Data | ⭐⭐ | | Healthcare | Internal | EMR/FHIR | Data | ⭐⭐⭐ | | Logistics | Internal | UPS/FedEx/USPS | None | ⭐⭐⭐ | | Smart Light | IoT | Edge API (Flask) | None | ⭐⭐ | | Greenhouse | IoT | Edge API (Flask) | Data | ⭐⭐ | | Camera | IoT | Edge API (Flask) | Data | ⭐⭐ | | Door Assistant | IoT | Edge API (Flask) + WhatsApp | Data | ⭐⭐ | ## Learn Concepts Through Demos See Key Concepts in action within real-world demos: **See in:** ALL demos Every demo shows LuaAgent configuration **See in:** ALL demos How to organize tools into skills **See in:** E-commerce, Support, Financial Real external event handling **See in:** HR, Finance, Support Scheduled automation examples **See in:** Healthcare, Financial Message validation and filtering **See in:** Healthcare, Financial, Support Response formatting and disclaimers ## Next Steps Easiest demo - Platform APIs only with LuaAgent Comprehensive demo with webhooks + jobs Learn lua-cli concepts before diving into demos Set up your development environment Complete API documentation Follow the first skill tutorial # Real Estate Assistant Source: https://docs.heylua.ai/demos/real-estate-assistant Property search with MLS API + Lua vector search ## Overview Real estate agent assistant integrating with **MLS (Multiple Listing Service) API** and **Lua Data API** for enhanced property search. **What it does:** * Search properties via MLS API * Save favorite properties * Schedule viewings * Compare properties * Get neighborhood info **APIs used:** External MLS API + Lua Data API ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { SearchPropertiesTool, GetPropertyDetailsTool, ScheduleViewingTool, SaveFavoriteTool, ComparePropertiesTool } from "./tools/RealEstateTools"; // Real estate skill const realEstateSkill = new LuaSkill({ name: "real-estate-assistant", description: "Real estate search and showing scheduler", context: ` This skill helps clients search and view properties. - search_properties: Search MLS for properties matching criteria - get_property_details: Get full details for a specific property - schedule_viewing: Book property showing appointment - save_favorite: Save property to client's favorites - compare_properties: Compare multiple properties side by side Always ask about budget, location preferences, and must-haves. Highlight unique features of properties. Be enthusiastic and professional. `, tools: [ new SearchPropertiesTool(), new GetPropertyDetailsTool(), new ScheduleViewingTool(), new SaveFavoriteTool(), new ComparePropertiesTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "real-estate-assistant", persona: `You are an experienced and enthusiastic real estate agent. Your role: - Help clients find their dream home - Search MLS listings based on preferences - Schedule property viewings - Provide market insights - Guide through the home-buying process Communication style: - Enthusiastic and positive - Professional and knowledgeable - Patient and understanding - Detail-oriented - Market-savvy Best practices: - Always ask about budget and location preferences - Inquire about must-have features vs nice-to-haves - Highlight unique property features - Provide honest market assessments - Suggest neighborhoods that match lifestyle - Offer to schedule viewings immediately When to escalate: - Making offers (requires human agent) - Legal questions (refer to attorney) - Complex financing (refer to mortgage specialist) - Commercial properties (different department)`, skills: [realEstateSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills. ### Tools Implementation ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Search Properties (External MLS API) export class SearchPropertiesTool implements LuaTool { name = "search_properties"; description = "Search real estate listings"; inputSchema = z.object({ location: z.string(), minPrice: z.number().optional(), maxPrice: z.number().optional(), bedrooms: z.number().optional(), propertyType: z.enum(['house', 'condo', 'apartment', 'townhouse']).optional() }); async execute(input: z.infer) { const mlsApiKey = env('MLS_API_KEY'); // Call MLS API const response = await fetch('https://api.mlslistings.com/api/v1/search', { method: 'POST', headers: { 'Authorization': `Bearer ${mlsApiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ location: input.location, price_min: input.minPrice, price_max: input.maxPrice, bedrooms: input.bedrooms, property_type: input.propertyType }) }); const data = await response.json(); // Save to Lua Data for vector search capabilities for (const property of data.listings) { const searchText = `${property.address} ${property.city} ${property.description} ${property.features.join(' ')}`; await Data.create('property_cache', property, searchText); } return { properties: data.listings.map(p => ({ id: p.mls_id, address: p.address, city: p.city, price: `$${p.price.toLocaleString()}`, bedrooms: p.bedrooms, bathrooms: p.bathrooms, sqft: p.square_feet, type: p.property_type, description: p.description.substring(0, 150) + '...' })), count: data.listings.length }; } } // 2. Save Favorite (Lua Data) export class SaveFavoriteTool implements LuaTool { name = "save_favorite"; description = "Save a property to favorites"; inputSchema = z.object({ mlsId: z.string(), notes: z.string().optional() }); async execute(input: z.infer) { await Data.create('favorites', { mlsId: input.mlsId, notes: input.notes, savedAt: new Date().toISOString() }); return { success: true, message: "Property saved to favorites" }; } } // 3. Schedule Viewing export class ScheduleViewingTool implements LuaTool { name = "schedule_viewing"; description = "Schedule a property viewing"; inputSchema = z.object({ mlsId: z.string(), date: z.string(), time: z.string(), clientName: z.string(), clientEmail: z.string().email(), clientPhone: z.string() }); async execute(input: z.infer) { const viewing = await Data.create('viewings', { ...input, status: 'scheduled', createdAt: new Date().toISOString() }); return { success: true, viewingId: viewing.id, message: `Viewing scheduled for ${input.date} at ${input.time}` }; } } ``` ## Environment Setup ```bash theme={null} # .env MLS_API_KEY=your_mls_api_key MLS_API_URL=https://api.mlslistings.com ``` ## Key Features Real MLS property data Semantic property search # Restaurant Ordering System Source: https://docs.heylua.ai/demos/restaurant-ordering Food ordering and reservations with Lua Platform APIs ## Overview Restaurant AI assistant for menu browsing, food ordering, and table reservations using **Lua Platform APIs**. **What it does:** * Browse menu with search * Create food orders * Make table reservations * Track order status * Handle special dietary requests **APIs used:** Lua Products API (menu), Data API (orders, reservations) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { BrowseMenuTool, SearchMenuTool, CreateOrderTool, TrackOrderTool, MakeReservationTool, CheckReservationTool } from "./tools/RestaurantTools"; // Restaurant ordering skill const restaurantSkill = new LuaSkill({ name: "restaurant-assistant", description: "Restaurant ordering and reservation assistant", context: ` This skill helps customers order food and make reservations. Menu & Ordering: - browse_menu: Show all menu items or filter by category - search_menu: Find specific dishes - create_order: Place food order (confirm items first!) - track_order: Check order preparation status Reservations: - make_reservation: Book a table - check_reservation: Look up reservation details Always mention daily specials. Ask about dietary restrictions and allergies. Confirm order details and total before submitting. `, tools: [ new BrowseMenuTool(), new SearchMenuTool(), new CreateOrderTool(), new TrackOrderTool(), new MakeReservationTool(), new CheckReservationTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "restaurant-assistant", persona: `You are a friendly restaurant host and server. Your role: - Help guests browse the menu - Take food and drink orders - Make table reservations - Answer questions about ingredients and preparation - Accommodate dietary restrictions Communication style: - Warm and welcoming - Enthusiastic about the food - Attentive to details - Patient with questions - Knowledgeable about the menu Best practices: - Always mention today's specials - Ask about dietary restrictions and allergies - Confirm order details before submitting - Suggest wine pairings or desserts - Provide estimated wait times - Thank guests warmly Menu knowledge: - All dishes and ingredients - Preparation methods - Portion sizes - Dietary information (vegan, gluten-free, etc.) - Chef's recommendations`, skills: [restaurantSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills. ### src/tools/RestaurantTools.ts ```typescript theme={null} import { LuaTool, Products, Data } from "lua-cli"; import { z } from "zod"; // 1. Browse Menu export class BrowseMenuTool implements LuaTool { name = "browse_menu"; description = "Browse restaurant menu items"; inputSchema = z.object({ category: z.enum(['appetizers', 'entrees', 'desserts', 'drinks', 'all']).default('all') }); async execute(input: z.infer) { // Use Products API for menu items (page 1, limit 100) const allItems = await Products.get(1, 100); let items = allItems.data; if (input.category !== 'all') { items = items.filter(item => item.category === input.category); } return { category: input.category, items: items.map(item => ({ id: item.id, name: item.name, description: item.description, price: `$${item.price.toFixed(2)}`, category: item.category, dietary: item.metadata?.dietary || [] })), dailySpecial: "Chef's seafood pasta - $24.99", message: `Showing ${items.length} ${input.category} items` }; } } // 2. Search Menu export class SearchMenuTool implements LuaTool { name = "search_menu"; description = "Search menu by dish name or ingredient"; inputSchema = z.object({ query: z.string().describe("Search query (dish name, ingredient, dietary requirement)") }); async execute(input: z.infer) { const results = await Products.search(input.query); return { results: results.map(item => ({ id: item.id, name: item.name, price: `$${item.price.toFixed(2)}`, description: item.description, dietary: item.metadata?.dietary })), count: results.length }; } } // 3. Create Order export class CreateOrderTool implements LuaTool { name = "create_order"; description = "Place a food order"; inputSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().min(1), specialInstructions: z.string().optional() })), customerName: z.string(), customerPhone: z.string(), orderType: z.enum(['dine-in', 'takeout', 'delivery']).default('dine-in'), deliveryAddress: z.string().optional() }); async execute(input: z.infer) { // Get all product details and calculate total let total = 0; const orderItems = []; for (const item of input.items) { const product = await Products.getById(item.productId); if (!product) { throw new Error(`Menu item not found: ${item.productId}`); } const itemTotal = product.price * item.quantity; total += itemTotal; orderItems.push({ name: product.name, quantity: item.quantity, price: product.price, subtotal: itemTotal, specialInstructions: item.specialInstructions }); } // Add delivery fee if applicable if (input.orderType === 'delivery') { total += 5.99; // Delivery fee } // Create order const order = await Data.create('restaurant_orders', { items: orderItems, total, customerName: input.customerName, customerPhone: input.customerPhone, orderType: input.orderType, deliveryAddress: input.deliveryAddress, status: 'preparing', orderNumber: this.generateOrderNumber(), createdAt: new Date().toISOString() }); return { success: true, orderId: order.id, orderNumber: order.data.orderNumber, items: orderItems, total: `$${total.toFixed(2)}`, estimatedTime: input.orderType === 'dine-in' ? '20-25 minutes' : '30-40 minutes', message: `Order #${order.data.orderNumber} placed! Estimated time: ${input.orderType === 'dine-in' ? '20-25' : '30-40'} minutes` }; } private generateOrderNumber(): string { return 'ORD-' + Date.now().toString().slice(-6); } private calculateNights(checkIn: string, checkOut: string): number { const start = new Date(checkIn); const end = new Date(checkOut); return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); } } // 4. Track Order export class TrackOrderTool implements LuaTool { name = "track_order"; description = "Check order preparation status"; inputSchema = z.object({ orderNumber: z.string().describe("Order number") }); async execute(input: z.infer) { const results = await Data.get('restaurant_orders', { orderNumber: input.orderNumber }); if (results.data.length === 0) { throw new Error(`Order not found: ${input.orderNumber}`); } const order = results.data[0]; const statusMessages = { preparing: "🍳 Your order is being prepared", ready: "✅ Your order is ready for pickup!", delivered: "🎉 Order delivered!", cancelled: "❌ Order was cancelled" }; return { orderNumber: order.data.orderNumber, status: order.data.status, statusMessage: statusMessages[order.data.status], items: order.data.items, total: `$${order.data.total.toFixed(2)}`, orderType: order.data.orderType }; } } // 5. Make Reservation export class MakeReservationTool implements LuaTool { name = "make_reservation"; description = "Reserve a table at the restaurant"; inputSchema = z.object({ date: z.string().describe("Reservation date (YYYY-MM-DD)"), time: z.string().describe("Reservation time (HH:MM)"), partySize: z.number().min(1).max(20), customerName: z.string(), customerPhone: z.string(), specialRequests: z.string().optional() }); async execute(input: z.infer) { // Create reservation const reservation = await Data.create('table_reservations', { date: input.date, time: input.time, partySize: input.partySize, customerName: input.customerName, customerPhone: input.customerPhone, specialRequests: input.specialRequests, status: 'confirmed', confirmationCode: this.generateConfirmationCode(), createdAt: new Date().toISOString() }, `${input.customerName} ${input.date} ${input.time}`); return { success: true, confirmationCode: reservation.data.confirmationCode, date: input.date, time: input.time, partySize: input.partySize, message: `Table reserved for ${input.partySize} on ${input.date} at ${input.time}. Confirmation: ${reservation.data.confirmationCode}` }; } private generateConfirmationCode(): string { return 'RES-' + Math.random().toString(36).substring(2, 10).toUpperCase(); } } // 6. Check Reservation export class CheckReservationTool implements LuaTool { name = "check_reservation"; description = "Look up table reservation"; inputSchema = z.object({ confirmationCode: z.string(), phone: z.string() }); async execute(input: z.infer) { const results = await Data.get('table_reservations', { confirmationCode: input.confirmationCode, customerPhone: input.phone }); if (results.data.length === 0) { throw new Error('Reservation not found'); } const reservation = results.data[0]; return { confirmationCode: reservation.data.confirmationCode, customerName: reservation.data.customerName, date: reservation.data.date, time: reservation.data.time, partySize: reservation.data.partySize, status: reservation.data.status, specialRequests: reservation.data.specialRequests }; } } ``` ## Key Features Uses Products API for menu Orders and reservations Complete restaurant solution Handles dietary restrictions # SaaS Onboarding Assistant Source: https://docs.heylua.ai/demos/saas-onboarding User onboarding with custom API + Lua vector search ## Overview SaaS onboarding assistant using **your product API** + **Lua Data API** for documentation search. **What it does:** * Guide new users through setup * Answer product questions * Create user accounts * Configure settings * Search documentation **APIs used:** Your SaaS API (external) + Lua Data API (docs) ## Complete Implementation ### src/index.ts ```typescript theme={null} import { LuaAgent, LuaSkill } from "lua-cli"; import { SearchDocsTool, CreateUserTool, GetUsageTool, ConfigureSettingsTool } from "./tools/SaaSTools"; // SaaS onboarding skill const saasSkill = new LuaSkill({ name: "saas-onboarding", description: "SaaS product onboarding and user assistance", context: ` This skill helps new users get started with our SaaS product. - search_docs: Find answers in product documentation - create_user: Create new user accounts - get_usage: Check usage statistics and limits - configure_settings: Help users configure their settings Guide users through setup step-by-step. Search documentation before creating support tickets. `, tools: [ new SearchDocsTool(), new CreateUserTool(), new GetUsageTool(), new ConfigureSettingsTool() ] }); // Configure agent export const agent = new LuaAgent({ name: "saas-onboarding-assistant", persona: `You are a friendly SaaS product onboarding specialist. Your role: - Guide new users through product setup - Help users understand features and capabilities - Assist with account configuration - Answer product questions - Provide usage tips and best practices Communication style: - Friendly and enthusiastic - Patient and encouraging - Clear and step-by-step - Supportive and helpful Best practices: - Break complex setup into simple steps - Celebrate user progress and milestones - Proactively offer relevant documentation - Suggest features based on user's use case - Provide keyboard shortcuts and tips - Encourage exploration of features When to escalate: - Technical integration issues - Custom enterprise requirements - Billing or payment questions - API or developer support`, skills: [saasSkill] }); ``` This demo uses `LuaAgent` to configure the agent's persona, welcome message, and skills. ### src/tools/SaaSTools.ts ```typescript theme={null} import { LuaTool, Data, env } from "lua-cli"; import { z } from "zod"; // 1. Search Documentation (Lua Vector Search) export class SearchDocsTool implements LuaTool { name = "search_docs"; description = "Search product documentation"; inputSchema = z.object({ query: z.string() }); async execute(input: z.infer) { const results = await Data.search('product_docs', input.query, 5, 0.7); return { articles: results.map(entry => ({ title: entry.title, content: entry.content.substring(0, 300), url: entry.url, relevance: entry.score })) }; } } // 2. Create User Account (Your SaaS API) export class CreateUserTool implements LuaTool { name = "create_user"; description = "Create new user account"; inputSchema = z.object({ email: z.string().email(), name: z.string(), company: z.string(), plan: z.enum(['starter', 'professional', 'enterprise']) }); async execute(input: z.infer) { const apiKey = env('SAAS_API_KEY'); // Call YOUR SaaS API const response = await fetch('https://your-saas-api.com/api/users', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input) }); const user = await response.json(); return { userId: user.id, email: user.email, setupUrl: `https://your-app.com/setup?token=${user.setupToken}`, message: `Account created! Check ${user.email} for setup instructions.` }; } } // 3. Get User Usage (Your SaaS API) export class GetUsageTool implements LuaTool { name = "get_usage"; description = "Check account usage and limits"; inputSchema = z.object({ userId: z.string() }); async execute(input: z.infer) { const apiKey = env('SAAS_API_KEY'); const response = await fetch( `https://your-saas-api.com/api/users/${input.userId}/usage`, { headers: { 'Authorization': `Bearer ${apiKey}` } } ); const usage = await response.json(); return { plan: usage.plan, apiCalls: `${usage.apiCalls.toLocaleString()} / ${usage.limits.apiCalls.toLocaleString()}`, storage: `${usage.storage}GB / ${usage.limits.storage}GB`, users: `${usage.users} / ${usage.limits.users}`, daysUntilRenewal: usage.daysUntilRenewal }; } } ``` ## Environment Setup ```bash theme={null} # .env SAAS_API_KEY=your_saas_api_key SAAS_API_URL=https://your-saas-api.com ``` ## Key Features YOUR SaaS backend Semantic doc search # How Devices Become Tools Source: https://docs.heylua.ai/devices/agent-tools Understand how device commands automatically appear as tools your agent can use ## Tool Naming Convention When a device connects with a list of commands, the agent runtime creates a tool for each command. The tool name follows this pattern: ``` device:{deviceName}:{commandName} ``` For example, a device named `label-printer` with a command `print_label` becomes the tool: ``` device:label-printer:print_label ``` The agent sees this tool alongside its regular skill tools and can invoke it in exactly the same way. ## Auto-Generated Tools The agent runtime automatically generates each device tool with: | Property | Source | | -------------------- | ------------------------------------------------------------------------------------ | | **Tool name** | `device:{deviceName}:{commandName}` | | **Tool description** | The `description` field from `DeviceCommandDefinition` | | **Input schema** | The `inputSchema` field from `DeviceCommandDefinition` (or no parameters if omitted) | | **Timeout** | The `timeoutMs` field (default: 30 seconds) | No `lua push` or `lua deploy` is needed. The tools appear the moment the device connects and disappear when it disconnects. ## Tips for Good Descriptions The description field is what the AI agent reads to decide whether to use the tool. Better descriptions lead to better tool selection. ```typescript theme={null} commands: [ { name: 'read_temperature', description: 'Read the current room temperature in celsius from the wall-mounted sensor', }, { name: 'print_label', description: 'Print a shipping label. Requires recipient name, address, city, and postal code. Optionally specify number of copies (1-10).', }, { name: 'scan_barcode', description: 'Activate the barcode scanner and return the scanned code. Takes 2-3 seconds. Returns the barcode value and format (UPC, QR, etc).', }, ] ``` These descriptions tell the agent: * What the command does * What it needs as input * What it returns * How long it takes (if relevant) ```typescript theme={null} commands: [ { name: 'read_temperature', description: 'temp', }, { name: 'print_label', description: 'prints stuff', }, { name: 'scan_barcode', description: 'scanner', }, ] ``` These descriptions are too vague. The agent will struggle to know when to use these tools or what parameters to pass. ## Persona Tips Your agent persona can reference devices to guide tool usage. Add instructions in your persona about when and how to use device tools: ```typescript theme={null} const agent = new LuaAgent({ name: 'warehouse-agent', persona: ` You are a warehouse operations assistant. You have access to physical devices in the warehouse: - Barcode scanners for inventory lookup - Weight scales for package verification - Gate controllers for dock access When a user asks about inventory, use the barcode scanner to look up items. When verifying shipments, use the weight scale to confirm package weight. Always confirm before opening or closing gates. If a device is offline, tell the user and suggest alternatives. `, skills: [inventorySkill], }); ``` ## Example Conversation Here is what a conversation looks like when an agent uses device tools: ``` User: What's the temperature in the server room? Agent: [internally selects tool: device:server-room-sensor:read_temperature] [sends command to device, receives: { temperature: 23.5, unit: 'celsius' }] The server room temperature is currently 23.5 degrees celsius. User: That seems a bit warm. Can you turn up the AC? Agent: [internally selects tool: device:server-room-hvac:set_target] [sends command with payload: { temperature: 21 }] [receives: { targetTemperature: 21, heatingOn: false }] Done. I've set the target temperature to 21 degrees celsius. The cooling system is now active. The temperature should come down within the next few minutes. User: Thanks. Let me know if it goes above 25. Agent: I'll keep an eye on it. If the temperature exceeds 25 degrees, I'll notify you immediately. [Note: this would be handled by a device trigger, not polling] ``` ## Multiple Devices An agent can have many devices connected at once. The device name in the tool name disambiguates them: ``` device:sensor-floor-1:read_temperature device:sensor-floor-2:read_temperature device:sensor-floor-3:read_temperature device:label-printer:print_label device:gate-controller:open_gate ``` The agent picks the right device based on the user's request and the tool descriptions. ## Offline Devices When a device disconnects, its tools are removed from the agent's tool list. The agent will not be able to use commands from an offline device. If a user asks for something that requires an offline device, the agent should explain that the device is unavailable. Design your persona to handle this gracefully. The agent does not know about devices that were previously connected but are now offline -- it only sees currently connected devices. ## Next Steps Detailed reference for DeviceCommandDefinition The other direction -- devices sending events to the agent See device tools in action Full working examples with multiple devices # Build Your Own Client Source: https://docs.heylua.ai/devices/build-your-own Full protocol specification for building a Lua device client in any language. ## Overview Lua devices communicate with agents over **MQTT** or **Socket.IO**. This page documents the wire protocol for clients in languages without an official SDK. If you're using **Node.js**, **Python**, or **MicroPython**, use the official SDKs instead. This page is for building clients in languages without an official SDK. ## Authentication First, [provision a device credential](/devices/credentials#provision-a-device-credential) for the exact agent, device name, and operations that your client uses. Then pass these values as MQTT connection parameters: | Parameter | MQTT Field | Format | Example | | ----------------- | ----------------------- | ----------------- | ------------------------ | | Agent ID | `username` (before `:`) | `{agentId}` | `baseAgent_agent_abc123` | | Device Name | `username` (after `:`) | `{deviceName}` | `warehouse-scanner` | | Device credential | `password` | Opaque credential | `api_.` | **MQTT username format:** `{agentId}:{deviceName}` Existing raw MQTT clients may keep using a non-dotted legacy key as the password indefinitely. **Connection settings:** * Broker: `wss://mqtt.heylua.ai/mqtt` (TLS required in production) * Client ID: `lua-{agentId}-{deviceName}` * Clean session: `false` (enables persistent session for QoS 1 message queueing) * Keep-alive: `60` seconds ## MQTT Topics All topics use the prefix `lua/devices/{agentId}/{deviceName}/`. ### Device Subscribes To (Server -> Device) | Topic Suffix | QoS | Description | Payload | | ---------------- | --- | --------------------------------- | --------------------------------------- | | `command` | 1 | Incoming command from agent | [CommandMessage](#commandmessage) | | `connected` | 1 | Server confirms device connection | `{"message": "..."}` | | `trigger_ack` | 1 | Server acknowledges a trigger | [TriggerAckMessage](#triggerackmessage) | | `trigger_result` | 1 | Result from trigger execution | `{"triggerName": "...", "result": ...}` | | `error` | 1 | Error from server | `{"code": "...", "message": "..."}` | ### Device Publishes To (Device -> Server) | Topic Suffix | QoS | Retain | Description | Payload | | ------------ | --- | ------- | ---------------------------------- | -------------------------------------------------------------------------- | | `status` | 1 | **Yes** | Online/offline status (no secrets) | [StatusMessage](#statusmessage-retained) | | `status` | 1 | **No** | Command manifest | [CommandManifestStatusMessage](#commandmanifeststatusmessage-non-retained) | | `response` | 1 | No | Command execution result | [ResponseMessage](#responsemessage) | | `trigger` | 1 | No | Fire a trigger to the agent | [TriggerMessage](#triggermessage) | | `heartbeat` | 0 | No | Keep-alive signal | Empty payload (`""`) | ## Socket.IO Events If you prefer WebSocket transport, connect to `{serverUrl}/devices` with Socket.IO. | Event | Direction | Description | Payload | | ---------------- | ---------------- | ------------------------ | ------------------------------------------------ | | `connected` | Server -> Device | Connection confirmed | `{}` | | `command` | Server -> Device | Incoming command | [CommandMessage](#commandmessage) + ack callback | | `trigger_ack` | Server -> Device | Trigger acknowledged | [TriggerAckMessage](#triggerackmessage) | | `trigger_result` | Server -> Device | Trigger execution result | `{triggerName, result}` | | `error` | Server -> Device | Error | `{code, message}` | | `response` | Device -> Server | Command result | [ResponseMessage](#responsemessage) | | `trigger` | Device -> Server | Fire trigger | [TriggerMessage](#triggermessage) | | `heartbeat` | Device -> Server | Keep-alive | `{}` | **Socket.IO auth** is passed in the `auth` option at connection time: The historical wire field is named `apiKey` even when it carries a device credential. ```json theme={null} { "apiKey": "", "agentId": "baseAgent_agent_abc123", "deviceName": "warehouse-scanner", "group": "warehouse-a", "commands": [...] } ``` `auth.apiKey` is the stable Socket.IO wire field for both device credentials and existing legacy keys. Do not rename this protocol field. ## Message Schemas ### CommandMessage Received on the `command` topic when the agent invokes a device command. ```json theme={null} { "commandId": "cmd_abc123", "command": "scan_barcode", "payload": { "format": "qr" }, "timeout": 30000 } ``` | Field | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------ | | `commandId` | string | Yes | Unique ID for idempotency | | `command` | string | Yes | Command name matching a registered handler | | `payload` | any | No | Input parameters for the command | | `timeout` | number | No | Timeout in milliseconds (default: 30000) | ### ResponseMessage Published to the `response` topic after executing a command. ```json theme={null} { "commandId": "cmd_abc123", "success": true, "data": { "barcode": "ABC-12345", "format": "CODE128" } } ``` ```json theme={null} { "commandId": "cmd_abc123", "success": false, "error": "Scanner hardware not responding" } ``` | Field | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------------ | | `commandId` | string | Yes | Must match the incoming command's ID | | `success` | boolean | Yes | Whether the command succeeded | | `data` | any | No | Result data (on success) | | `error` | string | No | Error message (on failure) | ### TriggerMessage Published to the `trigger` topic to fire an event to the agent. ```json theme={null} { "triggerName": "barcode_scanned", "payload": { "value": "ABC-12345", "location": "aisle-3" } } ``` | Field | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------ | | `triggerName` | string | Yes | Name of the trigger | | `payload` | any | No | Trigger data sent to the agent | ### TriggerAckMessage Received on the `trigger_ack` topic after the server processes a trigger. ```json theme={null} { "triggerId": "trg_abc123", "received": true } ``` | Field | Type | Required | Description | | ----------- | ------- | -------- | -------------------------------- | | `triggerId` | string | Yes | Server-assigned trigger ID | | `received` | boolean | Yes | Whether the trigger was accepted | | `error` | string | No | Error message if rejected | ### StatusMessage (retained) Published to the `status` topic with `retain: true`. This message must not contain a credential because the broker stores retained messages and delivers them to future subscribers. ```json theme={null} { "status": "online", "timestamp": "2026-04-17T10:30:00.000Z", "group": "warehouse-a" } ``` ### CommandManifestStatusMessage (non-retained) Published to the `status` topic with `retain: false` immediately after the retained status. A client that uses a device credential sends its command manifest without repeating the credential. The gateway uses the identity established during MQTT CONNECT. ```json theme={null} { "status": "online", "group": "warehouse-a", "commands": [ { "name": "scan_barcode", "description": "Scan a barcode and return its value", "inputSchema": { "type": "object", "properties": { "format": { "type": "string", "enum": ["qr", "code128", "ean13"] } } }, "timeoutMs": 30000 } ] } ``` An existing client that uses a non-dotted legacy key keeps the `apiKey` property in this non-retained message. Do not add `apiKey` when the CONNECT password is a typed device credential. ### Heartbeat Published to the `heartbeat` topic every 30 seconds with an empty payload. QoS 0 (fire-and-forget). ### Last Will and Testament (LWT) Set the MQTT LWT to publish an offline status if the device disconnects unexpectedly: * **Topic:** `lua/devices/{agentId}/{deviceName}/status` * **Payload:** `{"status": "offline", "timestamp": "..."}` * **QoS:** 1 * **Retain:** true ## Self-Describing Commands Commands are sent at connect time in the [CommandManifestStatusMessage](#commandmanifeststatusmessage-non-retained). The server registers permitted commands as agent tools automatically. ```json theme={null} { "name": "read_temperature", "description": "Read current temperature in celsius from the DHT22 sensor", "inputSchema": { "type": "object", "properties": { "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] } } }, "timeoutMs": 5000, "retry": { "maxAttempts": 3, "backoffMs": 1000 } } ``` | Field | Type | Required | Description | | ------------- | ------ | -------- | ----------------------------------------- | | `name` | string | Yes | Command name (used by agent to invoke) | | `description` | string | Yes | Shown to the AI agent as tool description | | `inputSchema` | object | No | JSON Schema for command parameters | | `timeoutMs` | number | No | Timeout in ms (default: 30000) | | `retry` | object | No | `{maxAttempts, backoffMs}` | ## Command Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> Idle Idle --> Received: command message arrives Received --> DedupCheck: parse commandId DedupCheck --> CachedResponse: commandId seen before DedupCheck --> HandlerLookup: new commandId CachedResponse --> PublishResponse: re-send cached response HandlerLookup --> ExecuteHandler: handler found HandlerLookup --> ErrorResponse: unknown command ExecuteHandler --> SuccessResponse: handler returns result ExecuteHandler --> ErrorResponse: handler throws exception SuccessResponse --> CacheAndPublish: cache + publish ErrorResponse --> CacheAndPublish: cache + publish CacheAndPublish --> PublishResponse PublishResponse --> Idle ``` ## Idempotency Commands include a `commandId` that must be used for idempotency. Your client should: 1. Maintain an LRU cache of recently seen `commandId` values (recommended: 1000 entries, 5-minute TTL) 2. On receiving a command, check if the `commandId` has been seen before 3. If seen, re-publish the cached response without re-executing the handler 4. If new, execute the handler, cache the response, then publish it This ensures that retried messages (common with QoS 1) do not cause duplicate side effects. Without idempotency handling, MQTT QoS 1 redelivery can cause commands to execute multiple times. Always implement the dedup cache. ## Rate Limits and Constraints | Constraint | Value | | ----------------------- | ------------ | | Max payload size (MQTT) | 256 KB | | Max commands per device | 50 | | Heartbeat interval | 30 seconds | | Trigger ACK timeout | 10 seconds | | Command default timeout | 30 seconds | | Dedup cache TTL | 5 minutes | | Dedup cache size | 1000 entries | | Reconnect base delay | 1 second | | Reconnect max delay | 30 seconds | | MQTT keep-alive | 60 seconds | ## Example Implementations ```go theme={null} package main import ( "encoding/json" "fmt" mqtt "github.com/eclipse/paho.mqtt.golang" "os" "os/signal" "time" ) func main() { agentID := "baseAgent_agent_abc123" deviceName := "go-sensor" deviceCredential := "your-device-credential" prefix := fmt.Sprintf("lua/devices/%s/%s/", agentID, deviceName) opts := mqtt.NewClientOptions(). AddBroker("wss://mqtt.heylua.ai/mqtt"). SetClientID(fmt.Sprintf("lua-%s-%s", agentID, deviceName)). SetUsername(fmt.Sprintf("%s:%s", agentID, deviceName)). SetPassword(deviceCredential). SetKeepAlive(60 * time.Second). SetCleanSession(false). SetWill(prefix+"status", `{"status":"offline","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`, 1, true) client := mqtt.NewClient(opts) if token := client.Connect(); token.Wait() && token.Error() != nil { panic(token.Error()) } // Subscribe to commands client.Subscribe(prefix+"command", 1, func(c mqtt.Client, msg mqtt.Message) { var cmd map[string]interface{} json.Unmarshal(msg.Payload(), &cmd) response := map[string]interface{}{ "commandId": cmd["commandId"], "success": true, "data": map[string]interface{}{"temp": 22.5}, } payload, _ := json.Marshal(response) c.Publish(prefix+"response", 1, false, payload) }) // Publish online status online, _ := json.Marshal(map[string]string{"status": "online", "timestamp": time.Now().UTC().Format(time.RFC3339)}) client.Publish(prefix+"status", 1, true, online) manifest, _ := json.Marshal(map[string]interface{}{"status": "online", "commands": []interface{}{}}) client.Publish(prefix+"status", 1, false, manifest) fmt.Println("Device connected. Press Ctrl+C to exit.") sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig client.Disconnect(250) } ``` ```rust theme={null} use rumqttc::{MqttOptions, AsyncClient, QoS, Event, Packet, LastWill}; use serde_json::{json, Value}; use tokio; #[tokio::main] async fn main() { let agent_id = "baseAgent_agent_abc123"; let device_name = "rust-sensor"; let device_credential = "your-device-credential"; let prefix = format!("lua/devices/{agent_id}/{device_name}/"); let mut opts = MqttOptions::new( format!("lua-{agent_id}-{device_name}"), "wss://mqtt.heylua.ai/mqtt", 443, ); opts.set_credentials(format!("{agent_id}:{device_name}"), device_credential); opts.set_keep_alive(std::time::Duration::from_secs(60)); opts.set_clean_session(false); opts.set_last_will(LastWill::new( format!("{prefix}status"), json!({"status": "offline"}).to_string(), QoS::AtLeastOnce, true, )); opts.set_transport(rumqttc::Transport::tls_with_default_config()); let (client, mut eventloop) = AsyncClient::new(opts, 10); client.subscribe(format!("{prefix}command"), QoS::AtLeastOnce).await.unwrap(); // Publish online status let online = json!({"status": "online"}).to_string(); client.publish(format!("{prefix}status"), QoS::AtLeastOnce, true, online).await.unwrap(); let manifest = json!({"status": "online", "commands": []}).to_string(); client.publish(format!("{prefix}status"), QoS::AtLeastOnce, false, manifest).await.unwrap(); println!("Device connected. Listening for commands..."); while let Ok(event) = eventloop.poll().await { if let Event::Incoming(Packet::Publish(msg)) = event { if msg.topic.ends_with("/command") { let cmd: Value = serde_json::from_slice(&msg.payload).unwrap(); let response = json!({ "commandId": cmd["commandId"], "success": true, "data": {"temp": 22.5} }); client.publish( format!("{prefix}response"), QoS::AtLeastOnce, false, response.to_string(), ).await.unwrap(); } } } } ``` ```csharp theme={null} using MQTTnet; using MQTTnet.Client; using System.Text; using System.Text.Json; var agentId = "baseAgent_agent_abc123"; var deviceName = "csharp-sensor"; var deviceCredential = "your-device-credential"; var prefix = $"lua/devices/{agentId}/{deviceName}/"; var factory = new MqttFactory(); var client = factory.CreateMqttClient(); var options = new MqttClientOptionsBuilder() .WithTcpServer("wss://mqtt.heylua.ai/mqtt", 443) .WithTlsOptions(o => o.UseTls()) .WithClientId($"lua-{agentId}-{deviceName}") .WithCredentials($"{agentId}:{deviceName}", deviceCredential) .WithKeepAlivePeriod(TimeSpan.FromSeconds(60)) .WithCleanSession(false) .WithWillTopic($"{prefix}status") .WithWillPayload(JsonSerializer.Serialize(new { status = "offline" })) .WithWillQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) .WithWillRetain(true) .Build(); client.ApplicationMessageReceivedAsync += async e => { if (e.ApplicationMessage.Topic.EndsWith("/command")) { var cmd = JsonSerializer.Deserialize(e.ApplicationMessage.PayloadSegment); var response = JsonSerializer.Serialize(new { commandId = cmd.GetProperty("commandId").GetString(), success = true, data = new { temp = 22.5 } }); await client.PublishStringAsync($"{prefix}response", response, MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce); } }; await client.ConnectAsync(options); await client.SubscribeAsync($"{prefix}command", MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce); // Publish online status await client.PublishStringAsync($"{prefix}status", JsonSerializer.Serialize(new { status = "online" }), MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce, true); await client.PublishStringAsync($"{prefix}status", JsonSerializer.Serialize(new { status = "online", commands = Array.Empty() }), MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce, false); Console.WriteLine("Device connected. Press Ctrl+C to exit."); await Task.Delay(Timeout.Infinite); ``` ```swift theme={null} import MQTTNIO import NIO import Foundation let agentId = "baseAgent_agent_abc123" let deviceName = "swift-sensor" let deviceCredential = "your-device-credential" let prefix = "lua/devices/\(agentId)/\(deviceName)/" let client = MQTTClient( configuration: .init( target: .webSocket("wss://mqtt.heylua.ai/mqtt"), tls: .forClient(certificateVerification: .fullVerification), clientId: "lua-\(agentId)-\(deviceName)", clean: false, credentials: .init( username: "\(agentId):\(deviceName)", password: deviceCredential ), willMessage: .init( topic: "\(prefix)status", payload: ByteBuffer(string: #"{"status":"offline"}"#), qos: .atLeastOnce, retain: true ), keepAliveInterval: .seconds(60) ), eventLoopGroupProvider: .createNew ) try client.connect().wait() try client.subscribe(to: [.init(topicFilter: "\(prefix)command", qos: .atLeastOnce)]).wait() // Publish online status let online = #"{"status":"online"}"# try client.publish(.init(topic: "\(prefix)status", payload: ByteBuffer(string: online), qos: .atLeastOnce, retain: true)).wait() let manifest = #"{"status":"online","commands":[]}"# try client.publish(.init(topic: "\(prefix)status", payload: ByteBuffer(string: manifest), qos: .atLeastOnce, retain: false)).wait() client.addPublishListener(named: "commands") { result in if case .success(let msg) = result, msg.topic.hasSuffix("/command") { guard let data = msg.payload.getData(at: 0, length: msg.payload.readableBytes), let cmd = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let commandId = cmd["commandId"] as? String else { return } let response: [String: Any] = [ "commandId": commandId, "success": true, "data": ["temp": 22.5] ] let payload = try! JSONSerialization.data(withJSONObject: response) _ = try? client.publish(.init(topic: "\(prefix)response", payload: ByteBuffer(data: payload), qos: .atLeastOnce, retain: false)).wait() } } print("Device connected. Listening for commands...") dispatchMain() ``` These examples show the minimal connect-and-handle pattern. Production clients should add: idempotency dedup, heartbeat loop, LWT, graceful shutdown, error handling, and auto-reconnect with exponential backoff. # CDN Uploads Source: https://docs.heylua.ai/devices/cdn-uploads Upload and download files between your device and the Lua CDN ## Overview Every `DeviceClient` instance includes a `cdn` property for uploading files such as screenshots, logs, and sensor data exports. The CDN uses the same credential as the device connection. A new device credential must include the `assets.upload` operation before this client can upload files. ## Methods ### upload Upload a file to the CDN. Returns a `CdnUploadResult` with the file ID and public URL. ```typescript theme={null} const result = await device.cdn.upload(data, filename, contentType); ``` File content as a Node.js Buffer or Blob. Filename including extension (e.g., `screenshot.png`, `sensor-log.csv`). MIME type (e.g., `image/png`, `text/csv`). Defaults to `application/octet-stream`. **Returns:** `CdnUploadResult` ```typescript theme={null} interface CdnUploadResult { fileId: string; // Unique file identifier mediaType: string; // Detected MIME type extension: string; // File extension url: string; // Public URL (https://cdn.heylua.ai/{fileId}) } ``` ### download Download a file from the CDN by its file ID. ```typescript theme={null} const buffer = await device.cdn.download(fileId); ``` The file ID returned from a previous upload. **Returns:** `Buffer` containing the file content. ### getUrl Get the public URL for a file without downloading it. ```typescript theme={null} const url = device.cdn.getUrl(fileId); // https://cdn.heylua.ai/{fileId} ``` The file ID returned from a previous upload. **Returns:** `string` -- the full CDN URL. ## Use Cases Kiosk devices capturing screen state for debugging or audit. Periodic CSV or JSON exports of accumulated sensor readings. Camera-equipped devices uploading inspection photos. Diagnostic reports uploaded for agent analysis. ## Example: Screenshot Upload A kiosk device that takes a screenshot when the agent requests it and uploads it to the CDN: ```typescript TypeScript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; import { execSync } from 'child_process'; import fs from 'fs'; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'lobby-kiosk', commands: [ { name: 'take_screenshot', description: 'Capture a screenshot of the kiosk display and upload it', }, { name: 'upload_logs', description: 'Upload the last hour of application logs', }, ], }); device.onCommand('take_screenshot', async () => { // Capture screenshot (Linux with scrot, macOS with screencapture) const tmpPath = '/tmp/kiosk-screenshot.png'; execSync(`screencapture -x ${tmpPath}`); const screenshot = fs.readFileSync(tmpPath); const result = await device.cdn.upload(screenshot, 'kiosk-screenshot.png', 'image/png'); return { message: 'Screenshot captured and uploaded', url: result.url, fileId: result.fileId, size: screenshot.length, }; }); device.onCommand('upload_logs', async () => { const logPath = '/var/log/kiosk/app.log'; const logContent = fs.readFileSync(logPath); const result = await device.cdn.upload( logContent, `kiosk-logs-${new Date().toISOString().slice(0, 10)}.log`, 'text/plain', ); return { message: 'Logs uploaded', url: result.url, fileId: result.fileId, lines: logContent.toString().split('\n').length, }; }); async function main() { await device.connect(); console.log('Kiosk device online'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os import subprocess from datetime import date from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="lobby-kiosk", commands=[ DeviceCommandDefinition( name="take_screenshot", description="Capture a screenshot of the kiosk display and upload it", ), DeviceCommandDefinition( name="upload_logs", description="Upload the last hour of application logs", ), ], ) @client.on_command("take_screenshot") async def handle_take_screenshot(payload): # Capture screenshot (Linux with scrot, macOS with screencapture) tmp_path = "/tmp/kiosk-screenshot.png" subprocess.run(["screencapture", "-x", tmp_path], check=True) with open(tmp_path, "rb") as f: screenshot = f.read() result = await client.cdn.upload(screenshot, "kiosk-screenshot.png", "image/png") return { "message": "Screenshot captured and uploaded", "url": result.url, "fileId": result.file_id, "size": len(screenshot), } @client.on_command("upload_logs") async def handle_upload_logs(payload): log_path = "/var/log/kiosk/app.log" with open(log_path, "rb") as f: log_content = f.read() result = await client.cdn.upload( log_content, f"kiosk-logs-{date.today().isoformat()}.log", "text/plain", ) return { "message": "Logs uploaded", "url": result.url, "fileId": result.file_id, "lines": log_content.decode().count("\n"), } async def main(): await client.connect() print("Kiosk device online") asyncio.run(main()) ``` ## Next Steps Full client reference including CDN property Complete kiosk example with CDN uploads CDN class method signatures # CLI Commands Source: https://docs.heylua.ai/devices/cli-commands Reference for all device-related CLI commands ## lua devices Manage connected devices from the command line. ```bash theme={null} lua devices [action] [options] ``` ### Actions | Action | Description | | -------------- | ------------------------------------------- | | *(none)* | Interactive device management | | `list` | List all connected devices | | `status` | Check the status of a specific device | | `enable` | Enable a device | | `disable` | Disable a device (stops receiving commands) | | `remove` | Remove a device from the agent | | `test` | Interactively test a device command | | `test-trigger` | Test a device trigger | ### Options | Flag | Description | | ---------------------- | ---------------------------------------------- | | `--device-name ` | Target device name | | `--group ` | Filter by device group | | `--payload ` | JSON payload for test commands (default: `{}`) | | `--force` | Skip confirmation prompts | ### Examples ```bash theme={null} # Interactive management lua devices # List all connected devices lua devices list # List devices in a specific group lua devices list --group warehouse-floor # Check device status lua devices status --device-name label-printer # Enable a device lua devices enable --device-name label-printer # Disable a device lua devices disable --device-name label-printer # Remove a device (with confirmation) lua devices remove --device-name label-printer # Remove without confirmation lua devices remove --device-name label-printer --force # Test a command interactively lua devices test --device-name label-printer # Test a trigger lua devices test-trigger --device-name label-printer ``` ## lua push device-trigger Push a compiled device trigger to the server. ```bash theme={null} lua push device-trigger [name] ``` Device triggers are agent-side primitives that handle events fired by devices. They are compiled, versioned, and pushed like other Lua primitives (skills, webhooks, jobs). ```bash theme={null} lua compile ``` The compiler detects `defineDeviceTrigger()` calls and bundles them. ```bash theme={null} # Push a specific device trigger lua push device-trigger temperature-alert # Push all primitives (including device triggers) lua push ``` ```bash theme={null} lua deploy ``` Activates the pushed version. ## lua compile The compiler automatically detects device-related primitives in your source code: * **`defineDeviceTrigger()`** calls are compiled as device trigger primitives * **`defineDevice()`** calls define device metadata (commands and inline triggers) No special flags are needed -- `lua compile` handles everything. ```bash theme={null} lua compile ``` Output includes device triggers alongside other primitives: ``` Compiled: Skills: 2 Webhooks: 1 Device Triggers: 3 Jobs: 0 ``` ## lua chat Test device interactions through the chat interface: ```bash theme={null} lua chat ``` When devices are connected, the agent has access to their commands as tools. You can test device interactions by chatting naturally: ``` > What devices are connected? > Read the temperature from pico-sensor > Turn on the LED on my device ``` The device must be running and connected for its tools to appear. If you do not see device tools, check that your device is online with `lua devices list`. ## Next Steps Connect your first device Device trigger development guide Full device client reference Device Gateway architecture # Device credentials Source: https://docs.heylua.ai/devices/credentials Provision and manage a credential for one exact device New Node.js installations and custom protocol clients use a device credential. The server binds each credential to one agent, one device name, and the device operations that you select. `@lua-ai-global/device-client` 1.1.0 and later support the `deviceCredential` option. The published Python 1.3.0 client and the current MicroPython distribution use `api_key` with existing non-dotted legacy keys. Those legacy configurations remain supported indefinitely. ## Choose the permitted operations Grant only the operations that the device uses. | Operation | Allows the device to | | --------------- | ------------------------------------------------------------------------ | | `commands` | Advertise command handlers, receive commands, and return command results | | `triggers` | Send device triggers to the agent | | `assets.upload` | Upload files through the device client's CDN helper | A device credential is different from a scoped personal API key. Lua accepts it only on supported device connections and device file uploads. A typed personal API key cannot replace a device credential on these device-only paths. Existing non-dotted legacy keys keep their permanent compatibility behavior. ## Provision a device credential The provisioning request requires a renewable first-party user session. Do not send an API key to this endpoint, and never store the user session on the device. The following example creates a credential for one device that receives commands and sends triggers: ```bash theme={null} curl --fail-with-body --silent --show-error \ --request POST "https://api.heylua.ai/admin/users/me/credentials/device" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" \ --header "Content-Type: application/json" \ --output response.json \ --data '{ "agentId": "baseAgent_agent_abc123", "deviceName": "warehouse-scanner", "operations": ["commands", "triggers"], "name": "Warehouse scanner" }' ``` `FIRST_PARTY_SESSION_TOKEN` is a placeholder for the current signed-in user's session token. It is not a Lua API key. The signed-in user must be allowed to issue credentials and perform every requested operation on the target agent. The endpoint returns `403` without creating a credential when either check fails. The request body accepts these fields: | Field | Required | Description | | ------------ | -------- | -------------------------------------------------------------------- | | `agentId` | Yes | The exact agent that the device may connect to | | `deviceName` | Yes | The exact device identity used by the client and MQTT topics | | `operations` | Yes | One or more values from `commands`, `triggers`, and `assets.upload` | | `name` | No | A label for credential management, up to 120 characters | | `expiresAt` | No | An absolute expiry time in epoch milliseconds. Omit it for no expiry | The `deviceName` must contain 1 to 200 characters. Do not use whitespace or the MQTT topic characters `/`, `+`, or `#`. The response identifies the credential class, exact binding, operations, and lifecycle state: | Response field | Value | | --------------------------- | ---------------------------------------------------------------------- | | `secret` | The full opaque credential. Lua returns it only after create or rotate | | `credential.id` | The stable identifier used by lifecycle endpoints | | `credential.credentialType` | `deviceCredential` | | `credential.status` | `active` after creation | | `credential.secretVersion` | The current secret version | | `credential.deviceBinding` | The exact `agentId`, `deviceName`, and `operations` from the request | Treat the secret as opaque. Do not parse its prefix or dotted format. ```bash theme={null} export LUA_DEVICE_CREDENTIAL="$(jq -r '.secret' response.json)" export LUA_DEVICE_CREDENTIAL_ID="$(jq -r '.credential.id' response.json)" ``` Store the secret in the device's secret store. Keep the credential identifier in your provisioning system so that you can rotate, suspend, or revoke the device later. The `agentId` and `deviceName` in the client configuration must exactly match the values used at provisioning. The gateway rejects a different agent or device name even when the secret is valid. ## Configure the device client Use `deviceCredential` with the Node.js client 1.1.0 or later: ```typescript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceName: 'warehouse-scanner', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, }); ``` The published Python 1.3.0 client and the current MicroPython distribution do not expose a typed credential option. Keep their existing `api_key` configuration unchanged. For raw MQTT clients, use `{agentId}:{deviceName}` as the username and the device credential as the MQTT CONNECT password. Do not include a typed device credential in a status payload. See [Build your own client](/devices/build-your-own). ## Manage the lifecycle All lifecycle requests require a renewable first-party user session. Replace `credential-id` with the stable `credential.id` returned at provisioning. List the signed-in user's personal and device credentials: ```bash theme={null} curl "https://api.heylua.ai/admin/users/me/credentials" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" ``` Device entries have `credentialType: "deviceCredential"` and include their `deviceBinding`. List responses never contain the secret. Replace the secret without changing the credential identifier, device binding, or permitted operations: ```bash theme={null} curl --request POST \ "https://api.heylua.ai/admin/users/me/credentials/credential-id/rotate" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" ``` Save the new `secret`, then replace the old secret on the device. Lua returns the new secret only in this response. Suspend the credential without deleting its binding: ```bash theme={null} curl --request POST \ "https://api.heylua.ai/admin/users/me/credentials/credential-id/suspend" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" ``` Reactivate an unexpired suspended credential: ```bash theme={null} curl --request POST \ "https://api.heylua.ai/admin/users/me/credentials/credential-id/reactivate" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" ``` Permanently revoke the credential: ```bash theme={null} curl --request DELETE \ "https://api.heylua.ai/admin/users/me/credentials/credential-id" \ --header "Authorization: Bearer $FIRST_PARTY_SESSION_TOKEN" ``` You cannot reactivate a revoked credential. Provision a new one if the device needs access again. Lifecycle changes disconnect affected Socket.IO and MQTT sessions as the change propagates. The authorization backstop applies within about one minute. ## Preserve existing installations Existing device settings remain compatible: * Node.js continues to accept `apiKey` indefinitely. * Python and MicroPython continue to accept `api_key` indefinitely. * Existing non-dotted legacy keys remain valid indefinitely. Lua does not rotate, rewrite, revoke, or expire them automatically. * Socket.IO continues to send the credential in the `auth.apiKey` wire field. * MQTT continues to send the credential as the CONNECT password. For Node.js and custom clients, that password can be the typed device credential. Published Python 1.3.0 and current MicroPython clients send their existing non-dotted legacy `api_key`. For new Node.js installations, use `deviceCredential`. This tells the MQTT client not to repeat the typed secret in the non-retained status message. If you provide both `deviceCredential` and `apiKey`, their values must match. ## Next steps Connect a Node.js or Python device Configure MQTT clients and topics # Factory Monitor Source: https://docs.heylua.ai/devices/examples/industrial-sensor Monitor industrial equipment with MicroPython on a Raspberry Pi Pico W ## Overview **Industry:** Manufacturing A factory floor monitoring system running on a Raspberry Pi Pico W. The device reads vibration and temperature sensors, provides an emergency stop command, and fires triggers when anomalies are detected. **Commands:** * `read_vibration` -- Read vibration amplitude from an accelerometer * `read_temperature` -- Read bearing temperature * `emergency_stop` -- Trigger the emergency stop relay **Triggers:** * `vibration_anomaly` -- Fires when vibration exceeds safe threshold The current MicroPython distribution uses `api_key` with an existing non-dotted legacy key. This configuration remains supported indefinitely. ## Device Client (MicroPython) ```python theme={null} import network import machine import time # -- WiFi -- wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect("FACTORY_WIFI", "secure_password_here") while not wlan.isconnected(): time.sleep(0.5) print("WiFi connected:", wlan.ifconfig()[0]) # -- Hardware setup -- # Onboard LED for status indication status_led = machine.Pin("LED", machine.Pin.OUT) # ADC for vibration sensor (analog accelerometer on GP26) vibration_adc = machine.ADC(machine.Pin(26)) # DS18B20 or analog temp sensor on GP27 temp_adc = machine.ADC(machine.Pin(27)) # Emergency stop relay on GP16 estop_relay = machine.Pin(16, machine.Pin.OUT, value=0) # -- Device -- from lua_device import LuaDevice device = LuaDevice( agent_id="your-agent-id", api_key="your-api-key", device_name="cnc-monitor-01", server="mqtt.heylua.ai", group="factory-floor", ) @device.command("read_vibration") def read_vibration(payload): # Read ADC value and convert to g-force (simplified) raw = vibration_adc.read_u16() amplitude_g = (raw / 65535.0) * 4.0 # 0-4g range return { "amplitude_g": round(amplitude_g, 3), "raw_adc": raw, "status": "warning" if amplitude_g > 2.5 else "normal", } @device.command("read_temperature") def read_temperature(payload): raw = temp_adc.read_u16() # Convert to celsius (simplified linear conversion) temp_c = (raw / 65535.0) * 150.0 # 0-150C range for bearing temp return { "temperature": round(temp_c, 1), "unit": "celsius", "status": "critical" if temp_c > 80 else "warning" if temp_c > 60 else "normal", } @device.command("emergency_stop") def emergency_stop(payload): estop_relay.on() # Activate relay (normally open -> closed) status_led.on() # Visual indicator reason = payload.get("reason", "manual trigger") print("[ESTOP] Emergency stop activated:", reason) return { "activated": True, "reason": reason, "timestamp": str(time.time()), } # -- Connect and run -- device.connect() status_led.on() # LED on = connected print("Factory monitor online") # -- Main loop with anomaly detection -- VIBRATION_THRESHOLD = 3.0 # g-force last_check = time.time() check_interval = 10 # seconds while True: try: device._client.check_msg() now = time.time() # Heartbeat if now - device._last_heartbeat >= device._heartbeat_interval: device._client.publish(device._topic_prefix + "heartbeat", b"", qos=0) device._last_heartbeat = now # Periodic vibration check if now - last_check >= check_interval: raw = vibration_adc.read_u16() amplitude = (raw / 65535.0) * 4.0 if amplitude > VIBRATION_THRESHOLD: device.trigger("vibration_anomaly", { "amplitude_g": round(amplitude, 3), "threshold_g": VIBRATION_THRESHOLD, "machine": "CNC-Mill-04", "location": "bay-3", }) # Blink LED rapidly to indicate alert for _ in range(5): status_led.toggle() time.sleep_ms(100) status_led.on() last_check = now # Clean dedup cache if len(device._seen_ids) > 100: device._clean_dedup() time.sleep_ms(100) except OSError as e: print("Connection lost:", e) status_led.off() device._reconnect() status_led.on() except Exception as e: print("Error:", e) time.sleep(1) ``` ## Agent-Side Trigger Handler ```typescript theme={null} // src/triggers/vibration-anomaly.ts import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const vibrationAnomaly = defineDeviceTrigger({ name: 'vibration-anomaly', description: 'Fired when a factory machine vibration reading exceeds the safe threshold', payloadSchema: z.object({ amplitude_g: z.number(), threshold_g: z.number(), machine: z.string(), location: z.string(), }), execute: async (payload, { agent, device }) => { const severity = payload.amplitude_g > payload.threshold_g * 1.5 ? 'CRITICAL' : 'WARNING'; await agent.chat( `${severity} VIBRATION ALERT from ${device.name}: ` + `Machine "${payload.machine}" in ${payload.location} ` + `reading ${payload.amplitude_g}g (threshold: ${payload.threshold_g}g). ` + (severity === 'CRITICAL' ? 'This is significantly above threshold. Consider activating emergency stop.' : 'Monitor closely and schedule maintenance if readings persist.') ); }, }); ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; import { vibrationAnomaly } from './triggers/vibration-anomaly'; const factorySkill = new LuaSkill({ name: 'factory-monitoring', description: 'Industrial equipment monitoring and control', context: ` You monitor factory equipment through connected sensors. Device tools: - read_vibration: Check machine vibration levels. Normal < 2.5g, warning < 3.0g, critical > 3.0g. - read_temperature: Check bearing temperature. Normal < 60C, warning < 80C, critical > 80C. - emergency_stop: CRITICAL COMMAND. Only use when explicitly requested or when readings indicate imminent equipment failure. Always confirm with the operator first unless automated shutdown is triggered. When receiving vibration anomaly triggers: 1. Read the current vibration and temperature 2. Assess combined risk 3. Recommend maintenance or emergency stop based on severity `, tools: [], }); export const agent = new LuaAgent({ name: 'factory-monitor', persona: `You are a factory floor monitoring assistant. You help operators keep equipment running safely. Be precise with readings. Escalate critical situations immediately. Never hesitate to recommend an emergency stop if readings indicate danger.`, skills: [factorySkill], deviceTriggers: [vibrationAnomaly], }); ``` ## Next Steps Step-by-step hardware setup guide Full MicroPython client reference Node.js logistics example How triggers flow from device to agent # Mac Controller Source: https://docs.heylua.ai/devices/examples/mac-controller Control your MacBook remotely through your AI agent ## Overview **Use case:** Personal computer automation via WhatsApp or web chat Turn your Mac into an AI-controllable device. Ask your agent to take screenshots, open apps, search files, control music, manage your clipboard, and more -- all from a chat message. **Commands:** * `take_screenshot` -- Capture the screen and return the image * `send_notification` -- Show a desktop notification * `open_url` -- Open a URL in the default browser * `open_app` -- Launch an application by name * `search_files` -- Find files using Spotlight * `get_active_app` -- Get the currently focused application * `system_info` -- Hostname, CPU, memory, uptime, battery * `get_clipboard` -- Read clipboard contents * `set_clipboard` -- Write text to the clipboard * `lock_screen` -- Lock the Mac * `play_music` -- Play, pause, or skip tracks in Apple Music or Spotify * `set_volume` -- Set the system volume (0--100) * `run_shortcut` -- Run a Shortcuts.app shortcut by name **Prerequisites:** * [Node.js](https://nodejs.org/) 18+ (for the Node.js client) or Python 3.10+ (for the Python client) * `terminal-notifier` for desktop notifications -- install with `brew install terminal-notifier` * Screen recording permission granted to your terminal app (for screenshots) For Node.js, [provision a device credential](/devices/credentials#provision-a-device-credential) with `commands` for the exact agent and device name in this example. The Python example uses `api_key` with an existing non-dotted legacy key. This key remains supported indefinitely. ## Device Client ```typescript Node.js theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; import { execSync } from 'child_process'; import { readFileSync, unlinkSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-macbook', group: 'personal-devices', commands: [ { name: 'take_screenshot', description: 'Capture a screenshot of the entire screen and return the image URL.', inputSchema: { type: 'object', properties: {} }, }, { name: 'send_notification', description: 'Show a macOS desktop notification.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Notification title' }, message: { type: 'string', description: 'Notification body text' }, }, required: ['title', 'message'], }, }, { name: 'open_url', description: 'Open a URL in the default browser.', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The URL to open' }, }, required: ['url'], }, }, { name: 'open_app', description: 'Launch an application by name (e.g., "Slack", "Safari", "Terminal").', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Application name' }, }, required: ['name'], }, }, { name: 'search_files', description: 'Search for files by name using Spotlight (mdfind).', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Filename or search term' }, limit: { type: 'number', description: 'Max results (default 10)' }, }, required: ['query'], }, }, { name: 'get_active_app', description: 'Get the name of the currently focused application.', inputSchema: { type: 'object', properties: {} }, }, { name: 'system_info', description: 'Get system information: hostname, CPU, memory, uptime, and battery level.', inputSchema: { type: 'object', properties: {} }, }, { name: 'get_clipboard', description: 'Read the current clipboard text contents.', inputSchema: { type: 'object', properties: {} }, }, { name: 'set_clipboard', description: 'Set the clipboard text contents.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to copy to clipboard' }, }, required: ['text'], }, }, { name: 'lock_screen', description: 'Lock the Mac screen immediately.', inputSchema: { type: 'object', properties: {} }, }, { name: 'play_music', description: 'Control music playback: play, pause, or skip to next/previous track.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['play', 'pause', 'next', 'previous'], description: 'Playback action', }, app: { type: 'string', enum: ['Music', 'Spotify'], description: 'Music app to control (default: Music)', }, }, required: ['action'], }, }, { name: 'set_volume', description: 'Set the system output volume.', inputSchema: { type: 'object', properties: { level: { type: 'number', minimum: 0, maximum: 100, description: 'Volume level 0-100' }, }, required: ['level'], }, }, { name: 'run_shortcut', description: 'Run a macOS Shortcuts.app shortcut by name.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Shortcut name' }, }, required: ['name'], }, }, ], }); device.onCommand('take_screenshot', async () => { const filepath = join(tmpdir(), `screenshot-${Date.now()}.png`); execSync(`screencapture -x ${filepath}`); const buffer = readFileSync(filepath); const url = await device.uploadFile(buffer, `screenshot-${Date.now()}.png`, 'image/png'); unlinkSync(filepath); return { imageUrl: url, timestamp: new Date().toISOString() }; }); device.onCommand('send_notification', async (payload) => { const title = payload.title.replace(/"/g, '\\"'); const message = payload.message.replace(/"/g, '\\"'); execSync(`terminal-notifier -title "${title}" -message "${message}"`); return { sent: true, title: payload.title, message: payload.message }; }); device.onCommand('open_url', async (payload) => { execSync(`open "${payload.url}"`); return { opened: true, url: payload.url }; }); device.onCommand('open_app', async (payload) => { execSync(`open -a "${payload.name}"`); return { opened: true, app: payload.name }; }); device.onCommand('search_files', async (payload) => { const limit = payload.limit || 10; const raw = execSync(`mdfind -name "${payload.query}" | head -${limit}`).toString().trim(); const files = raw ? raw.split('\n') : []; return { query: payload.query, results: files, count: files.length }; }); device.onCommand('get_active_app', async () => { const script = 'tell application "System Events" to get name of first application process whose frontmost is true'; const name = execSync(`osascript -e '${script}'`).toString().trim(); return { activeApp: name }; }); device.onCommand('system_info', async () => { const hostname = execSync('hostname').toString().trim(); const cpu = execSync('sysctl -n machdep.cpu.brand_string').toString().trim(); const memBytes = parseInt(execSync('sysctl -n hw.memsize').toString().trim()); const memGB = Math.round(memBytes / 1073741824); const uptime = execSync('uptime').toString().trim(); let battery = 'N/A'; try { battery = execSync('pmset -g batt | grep -o "[0-9]*%"').toString().trim(); } catch {} return { hostname, cpu, memoryGB: memGB, uptime, battery }; }); device.onCommand('get_clipboard', async () => { const text = execSync('osascript -e "the clipboard"').toString().trim(); return { clipboard: text }; }); device.onCommand('set_clipboard', async (payload) => { const escaped = payload.text.replace(/"/g, '\\"'); execSync(`osascript -e 'set the clipboard to "${escaped}"'`); return { set: true, text: payload.text }; }); device.onCommand('lock_screen', async () => { execSync('pmset displaysleepnow'); return { locked: true, timestamp: new Date().toISOString() }; }); device.onCommand('play_music', async (payload) => { const app = payload.app || 'Music'; const actionMap: Record = { play: 'play', pause: 'pause', next: 'next track', previous: 'previous track', }; const command = actionMap[payload.action]; execSync(`osascript -e 'tell application "${app}" to ${command}'`); return { action: payload.action, app }; }); device.onCommand('set_volume', async (payload) => { const vol = Math.round((payload.level / 100) * 7); execSync(`osascript -e 'set volume output volume ${payload.level}'`); return { volume: payload.level }; }); device.onCommand('run_shortcut', async (payload) => { execSync(`shortcuts run "${payload.name}"`); return { ran: true, shortcut: payload.name }; }); async function main() { await device.connect(); console.log('Mac controller online'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os import subprocess import tempfile from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="my-macbook", group="personal-devices", commands=[ DeviceCommandDefinition( name="take_screenshot", description="Capture a screenshot of the entire screen and return the image URL.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="send_notification", description="Show a macOS desktop notification.", input_schema={ "type": "object", "properties": { "title": {"type": "string", "description": "Notification title"}, "message": {"type": "string", "description": "Notification body text"}, }, "required": ["title", "message"], }, ), DeviceCommandDefinition( name="open_url", description="Open a URL in the default browser.", input_schema={ "type": "object", "properties": { "url": {"type": "string", "description": "The URL to open"}, }, "required": ["url"], }, ), DeviceCommandDefinition( name="open_app", description="Launch an application by name (e.g., 'Slack', 'Safari', 'Terminal').", input_schema={ "type": "object", "properties": { "name": {"type": "string", "description": "Application name"}, }, "required": ["name"], }, ), DeviceCommandDefinition( name="search_files", description="Search for files by name using Spotlight (mdfind).", input_schema={ "type": "object", "properties": { "query": {"type": "string", "description": "Filename or search term"}, "limit": {"type": "number", "description": "Max results (default 10)"}, }, "required": ["query"], }, ), DeviceCommandDefinition( name="get_active_app", description="Get the name of the currently focused application.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="system_info", description="Get system information: hostname, CPU, memory, uptime, and battery level.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="get_clipboard", description="Read the current clipboard text contents.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="set_clipboard", description="Set the clipboard text contents.", input_schema={ "type": "object", "properties": { "text": {"type": "string", "description": "Text to copy to clipboard"}, }, "required": ["text"], }, ), DeviceCommandDefinition( name="lock_screen", description="Lock the Mac screen immediately.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="play_music", description="Control music playback: play, pause, or skip to next/previous track.", input_schema={ "type": "object", "properties": { "action": { "type": "string", "enum": ["play", "pause", "next", "previous"], "description": "Playback action", }, "app": { "type": "string", "enum": ["Music", "Spotify"], "description": "Music app to control (default: Music)", }, }, "required": ["action"], }, ), DeviceCommandDefinition( name="set_volume", description="Set the system output volume.", input_schema={ "type": "object", "properties": { "level": {"type": "number", "minimum": 0, "maximum": 100, "description": "Volume level 0-100"}, }, "required": ["level"], }, ), DeviceCommandDefinition( name="run_shortcut", description="Run a macOS Shortcuts.app shortcut by name.", input_schema={ "type": "object", "properties": { "name": {"type": "string", "description": "Shortcut name"}, }, "required": ["name"], }, ), ], ) @client.on_command("take_screenshot") async def handle_take_screenshot(payload): filepath = os.path.join(tempfile.gettempdir(), f"screenshot-{int(datetime.now().timestamp())}.png") subprocess.run(["screencapture", "-x", filepath], check=True) with open(filepath, "rb") as f: buffer = f.read() url = await client.upload_file(buffer, f"screenshot-{int(datetime.now().timestamp())}.png", "image/png") os.unlink(filepath) return {"imageUrl": url, "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("send_notification") async def handle_send_notification(payload): subprocess.run( ["terminal-notifier", "-title", payload["title"], "-message", payload["message"]], check=True, ) return {"sent": True, "title": payload["title"], "message": payload["message"]} @client.on_command("open_url") async def handle_open_url(payload): subprocess.run(["open", payload["url"]], check=True) return {"opened": True, "url": payload["url"]} @client.on_command("open_app") async def handle_open_app(payload): subprocess.run(["open", "-a", payload["name"]], check=True) return {"opened": True, "app": payload["name"]} @client.on_command("search_files") async def handle_search_files(payload): limit = payload.get("limit", 10) result = subprocess.run( ["mdfind", "-name", payload["query"]], capture_output=True, text=True, ) files = [f for f in result.stdout.strip().split("\n") if f][:limit] return {"query": payload["query"], "results": files, "count": len(files)} @client.on_command("get_active_app") async def handle_get_active_app(payload): script = 'tell application "System Events" to get name of first application process whose frontmost is true' result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) return {"activeApp": result.stdout.strip()} @client.on_command("system_info") async def handle_system_info(payload): hostname = subprocess.run(["hostname"], capture_output=True, text=True).stdout.strip() cpu = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True).stdout.strip() mem_bytes = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout.strip()) mem_gb = round(mem_bytes / 1073741824) uptime = subprocess.run(["uptime"], capture_output=True, text=True).stdout.strip() try: batt = subprocess.run("pmset -g batt | grep -o '[0-9]*%'", shell=True, capture_output=True, text=True).stdout.strip() except Exception: batt = "N/A" return {"hostname": hostname, "cpu": cpu, "memoryGB": mem_gb, "uptime": uptime, "battery": batt or "N/A"} @client.on_command("get_clipboard") async def handle_get_clipboard(payload): result = subprocess.run(["osascript", "-e", "the clipboard"], capture_output=True, text=True) return {"clipboard": result.stdout.strip()} @client.on_command("set_clipboard") async def handle_set_clipboard(payload): escaped = payload["text"].replace('"', '\\"') subprocess.run(["osascript", "-e", f'set the clipboard to "{escaped}"'], check=True) return {"set": True, "text": payload["text"]} @client.on_command("lock_screen") async def handle_lock_screen(payload): subprocess.run(["pmset", "displaysleepnow"], check=True) return {"locked": True, "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("play_music") async def handle_play_music(payload): app = payload.get("app", "Music") action_map = {"play": "play", "pause": "pause", "next": "next track", "previous": "previous track"} command = action_map[payload["action"]] subprocess.run(["osascript", "-e", f'tell application "{app}" to {command}'], check=True) return {"action": payload["action"], "app": app} @client.on_command("set_volume") async def handle_set_volume(payload): subprocess.run(["osascript", "-e", f'set volume output volume {payload["level"]}'], check=True) return {"volume": payload["level"]} @client.on_command("run_shortcut") async def handle_run_shortcut(payload): subprocess.run(["shortcuts", "run", payload["name"]], check=True) return {"ran": True, "shortcut": payload["name"]} async def main(): await client.connect() print("Mac controller online") asyncio.run(main()) ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; const macControlSkill = new LuaSkill({ name: 'mac-controller', description: 'Control a MacBook remotely', context: ` You are a personal assistant that controls a MacBook. You can take screenshots, open apps, search files, control music, manage clipboard, and more. Device tools: - take_screenshot: Captures the screen and returns an image URL. - send_notification: Shows a desktop notification with a title and message. - open_url: Opens a URL in the default browser. - open_app: Launches a Mac application by name. - search_files: Searches for files using Spotlight. Great for finding documents. - get_active_app: Reports which application is currently in the foreground. - system_info: Returns hostname, CPU, memory, uptime, and battery status. - get_clipboard: Reads the current clipboard text. - set_clipboard: Sets the clipboard to the provided text. - lock_screen: Locks the Mac immediately. - play_music: Controls Apple Music or Spotify (play, pause, next, previous). - set_volume: Sets the system volume from 0 to 100. - run_shortcut: Runs a Shortcuts.app shortcut by name. Guidelines: - When asked for a screenshot, take it and share the image URL - For file searches, show the full paths in the results - Confirm destructive actions (like locking the screen) before executing - Be conversational and helpful `, tools: [], }); export const agent = new LuaAgent({ name: 'mac-assistant', persona: `You are a personal assistant that controls a MacBook. You can take screenshots, open apps, search files, control music, manage the clipboard, and automate tasks. Be helpful, concise, and confirm before taking potentially disruptive actions like locking the screen.`, skills: [macControlSkill], }); ``` ## What You Can Ask Here are real conversational examples you can send from WhatsApp or web chat: "Take a screenshot and send it to me" "Open Slack" / "Launch Safari" "Search for files called invoice.pdf" "What app am I currently using?" "Lock my Mac" "Set the volume to 30%" "Play the next song" / "Pause the music" "Copy this text to my clipboard: Meeting at 3pm" "How much battery do I have left?" "Send me a notification that says Stand up and stretch" "Open github.com in my browser" "Run my Focus Mode shortcut" **Security note:** This device client gives your AI agent direct control over your computer. Only run it on machines you trust, and consider limiting which commands are registered based on your comfort level. ## Next Steps Control a Windows PC the same way Office automation with sensors and displays How screenshot uploads work How to write effective command definitions # Raspberry Pi Pico W Setup Source: https://docs.heylua.ai/devices/examples/raspberry-pi-pico Step-by-step guide to running a Lua device on a Raspberry Pi Pico W ## Hardware List | Item | Purpose | Notes | | ----------------------- | ------------------------ | ------------------------------------- | | Raspberry Pi Pico W | Main board | Must be the **W** variant (with WiFi) | | Micro-USB cable | Power and programming | Data-capable, not charge-only | | DHT22 sensor (optional) | Temperature and humidity | Connect to GP15 with 10k pull-up | | LED (optional) | Status indicator | Onboard LED works for basic use | | Breadboard + jumpers | Wiring | For connecting sensors | ## Setup with Thonny Download [Thonny](https://thonny.org/) for your operating system. Thonny has built-in MicroPython support for the Pico W. 1. Hold the **BOOTSEL** button on the Pico W 2. While holding, connect the USB cable to your computer 3. Release BOOTSEL -- the Pico appears as a USB drive 4. Download the latest `.uf2` firmware from [micropython.org/download/RPI\_PICO\_W](https://micropython.org/download/RPI_PICO_W/) 5. Drag the `.uf2` file onto the Pico USB drive 6. The Pico reboots automatically with MicroPython 1. Open Thonny 2. Go to **Tools > Options > Interpreter** 3. Select **MicroPython (Raspberry Pi Pico)** 4. Select the correct port (usually auto-detected) 5. Click **OK** 6. You should see the MicroPython REPL in the bottom panel 1. Open `lua_device.py` in Thonny 2. Go to **File > Save as...** 3. Select **Raspberry Pi Pico** as the target 4. Save as `lua_device.py` on the Pico Create a `config.py` file on the Pico with your WiFi and Lua credentials: ```python theme={null} # config.py -- save this to the Pico WIFI_SSID = "YOUR_WIFI_NETWORK" WIFI_PASSWORD = "YOUR_WIFI_PASSWORD" LUA_AGENT_ID = "your-agent-id" LUA_API_KEY = "your-api-key" LUA_DEVICE_NAME = "pico-sensor" ``` Keep `config.py` on the Pico only. Do not commit WiFi passwords or API keys to version control. The current MicroPython client uses an existing non-dotted legacy key, which remains supported indefinitely. Create the main application file. This runs automatically when the Pico boots: ```python theme={null} # main.py -- save this to the Pico import network import machine import time import config # -- WiFi connection -- led = machine.Pin("LED", machine.Pin.OUT) led.off() wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect(config.WIFI_SSID, config.WIFI_PASSWORD) print("Connecting to WiFi...") timeout = 30 while not wlan.isconnected() and timeout > 0: led.toggle() time.sleep(0.5) timeout -= 1 if not wlan.isconnected(): print("WiFi connection failed!") # Blink rapidly to indicate error for _ in range(20): led.toggle() time.sleep_ms(100) machine.reset() led.on() print("WiFi connected:", wlan.ifconfig()[0]) # -- Optional: DHT22 sensor -- try: import dht dht_sensor = dht.DHT22(machine.Pin(15)) has_dht = True print("DHT22 sensor detected on GP15") except Exception: has_dht = False print("No DHT22 sensor -- using simulated values") # -- Device setup -- from lua_device import LuaDevice device = LuaDevice( agent_id=config.LUA_AGENT_ID, api_key=config.LUA_API_KEY, device_name=config.LUA_DEVICE_NAME, server="mqtt.heylua.ai", ) @device.command("led_on") def led_on(payload): led.on() return {"status": "on"} @device.command("led_off") def led_off(payload): led.off() return {"status": "off"} @device.command("blink") def blink(payload): count = payload.get("count", 3) for _ in range(count): led.on() time.sleep_ms(200) led.off() time.sleep_ms(200) led.on() # leave on (connected state) return {"blinked": count} @device.command("read_environment") def read_environment(payload): if has_dht: dht_sensor.measure() return { "temperature": dht_sensor.temperature(), "humidity": dht_sensor.humidity(), "source": "DHT22", } else: return { "temperature": 22.0 + (time.ticks_ms() % 50) / 10, "humidity": 55 + (time.ticks_ms() % 200) / 10, "source": "simulated", } @device.command("system_info") def system_info(payload): import gc gc.collect() return { "free_memory": gc.mem_free(), "ip_address": wlan.ifconfig()[0], "rssi": wlan.status("rssi"), "uptime_ms": time.ticks_ms(), } # -- Connect and run -- device.connect() print("Lua device connected -- listening for commands") device.run() ``` 1. Click the green **Run** button in Thonny (or press F5) 2. You should see: ``` Connecting to WiFi... WiFi connected: 192.168.1.42 DHT22 sensor detected on GP15 [lua-device] Connected as pico-sensor Lua device connected -- listening for commands [lua-device] Listening for commands... ``` 3. Open `lua chat` in your terminal and try: ``` > Turn on the LED on pico-sensor > Read the environment from pico-sensor > Blink the LED 5 times > What's the system info on pico-sensor? ``` ## File Structure on Pico After setup, the Pico should have these files: ``` / ├── lua_device.py # Lua device client library ├── config.py # WiFi and API credentials └── main.py # Your application (runs on boot) ``` ## Troubleshooting * Make sure you are holding **BOOTSEL** before plugging in the USB cable * Try a different USB cable (some are charge-only, not data) * Try a different USB port on your computer * Pico W only supports 2.4 GHz WiFi (not 5 GHz) * Check SSID and password in `config.py` (case-sensitive) * Move the Pico closer to the router * Check `wlan.status()` for error codes: -1 = connection failed, -2 = no matching SSID, -3 = auth failed * Verify `agent_id`, `api_key`, and `device_name` in `config.py` * Check WiFi is connected first (`wlan.isconnected()`) * Ensure port 443 (WebSocket) is not blocked by your network * Try `device = LuaDevice(..., use_ssl=False)` temporarily for debugging * Add `import gc; gc.collect()` periodically in your code * Compile `lua_device.py` to bytecode: `mpy-cross lua_device.py`, then copy `lua_device.mpy` * Reduce payload sizes in command responses * Keep the dedup cache small (it auto-cleans at 100 entries) * Check the REPL output for error messages * Verify command names match between the handler and what the agent expects * Ensure `device.run()` is being called (it is the message processing loop) * Check that `device.connect()` completed without errors * Check wiring: data pin to GP15, 10k pull-up resistor between data and 3.3V * DHT22 requires at least 2 seconds between readings * Try `dht_sensor.measure()` in the REPL first to verify ## Next Steps Full LuaDevice class reference Industrial monitoring example on Pico W MQTT topic structure and QoS details Send events from the Pico W to your agent # Retail Kiosk Source: https://docs.heylua.ai/devices/examples/retail-kiosk Build an AI-powered retail kiosk with receipt printing, NFC, and customer help requests ## Overview **Industry:** Retail A customer-facing kiosk that prints receipts, reads NFC loyalty cards, displays messages on screen, and lets customers request help from the AI agent. **Commands:** * `print_receipt` -- Print a formatted receipt * `read_nfc_card` -- Read an NFC loyalty card * `display_message` -- Show a message on the kiosk screen **Triggers:** * `customer_help_requested` -- Fires when a customer presses the help button For Node.js, [provision a device credential](/devices/credentials#provision-a-device-credential) with `commands` and `triggers` for the exact agent and device name in this example. The Python example uses `api_key` with an existing non-dotted legacy key. This key remains supported indefinitely. ## Device Client ```typescript Node.js theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; // Simulated hardware interfaces const printer = { print: async (lines: string[]) => { console.log('--- RECEIPT ---'); lines.forEach(l => console.log(l)); console.log('--- END ---'); return { printed: true, lineCount: lines.length }; }, }; const nfc = { read: async () => ({ cardId: 'NFC-8847-2024', memberName: 'Jane Smith', tier: 'Gold', points: 2450, }), }; const display = { show: async (message: string, style: string) => { console.log(`[DISPLAY ${style}] ${message}`); return { displayed: true }; }, }; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'store-kiosk-01', group: 'retail-kiosks', commands: [ { name: 'print_receipt', description: 'Print a receipt on the kiosk thermal printer. Provide an array of text lines to print.', inputSchema: { type: 'object', properties: { lines: { type: 'array', items: { type: 'string' }, description: 'Array of text lines to print on the receipt', }, header: { type: 'string', description: 'Store name or header text' }, footer: { type: 'string', description: 'Footer text (e.g., return policy)' }, }, required: ['lines'], }, timeoutMs: 15000, }, { name: 'read_nfc_card', description: 'Activate the NFC reader and scan a loyalty card. Returns member name, tier, and points balance. Prompts the customer to tap their card.', timeoutMs: 30000, }, { name: 'display_message', description: 'Show a message on the kiosk display screen', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'Message text to display' }, style: { type: 'string', enum: ['welcome', 'info', 'success', 'error'], default: 'info' }, }, required: ['message'], }, }, ], }); device.onCommand('print_receipt', async (payload) => { const lines: string[] = []; if (payload.header) { lines.push('================================'); lines.push(` ${payload.header}`); lines.push('================================'); } lines.push(''); lines.push(...(payload.lines || [])); lines.push(''); if (payload.footer) { lines.push('--------------------------------'); lines.push(payload.footer); } lines.push(` ${new Date().toLocaleString()}`); const result = await printer.print(lines); return { ...result, totalLines: lines.length }; }); device.onCommand('read_nfc_card', async () => { await display.show('Please tap your loyalty card...', 'info'); const card = await nfc.read(); await display.show(`Welcome back, ${card.memberName}!`, 'success'); return card; }); device.onCommand('display_message', async (payload) => { const result = await display.show(payload.message, payload.style || 'info'); return { ...result, message: payload.message }; }); async function main() { await device.connect(); console.log('Retail kiosk online'); await display.show('Welcome! How can I help you today?', 'welcome'); // Simulate a customer pressing the help button every few minutes setInterval(async () => { if (Math.random() < 0.1) { await device.trigger('customer_help_requested', { kiosk: 'store-kiosk-01', location: 'entrance', timestamp: new Date().toISOString(), }); } }, 30000); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os import random from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition # Simulated hardware interfaces async def printer_print(lines): print("--- RECEIPT ---") for line in lines: print(line) print("--- END ---") return {"printed": True, "lineCount": len(lines)} async def nfc_read(): return { "cardId": "NFC-8847-2024", "memberName": "Jane Smith", "tier": "Gold", "points": 2450, } async def display_show(message, style): print(f"[DISPLAY {style}] {message}") return {"displayed": True} client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="store-kiosk-01", group="retail-kiosks", commands=[ DeviceCommandDefinition( name="print_receipt", description="Print a receipt on the kiosk thermal printer. Provide an array of text lines to print.", input_schema={ "type": "object", "properties": { "lines": { "type": "array", "items": {"type": "string"}, "description": "Array of text lines to print on the receipt", }, "header": {"type": "string", "description": "Store name or header text"}, "footer": {"type": "string", "description": "Footer text (e.g., return policy)"}, }, "required": ["lines"], }, timeout_ms=15000, ), DeviceCommandDefinition( name="read_nfc_card", description="Activate the NFC reader and scan a loyalty card. Returns member name, tier, and points balance. Prompts the customer to tap their card.", timeout_ms=30000, ), DeviceCommandDefinition( name="display_message", description="Show a message on the kiosk display screen", input_schema={ "type": "object", "properties": { "message": {"type": "string", "description": "Message text to display"}, "style": {"type": "string", "enum": ["welcome", "info", "success", "error"], "default": "info"}, }, "required": ["message"], }, ), ], ) @client.on_command("print_receipt") async def handle_print_receipt(payload): lines = [] if payload.get("header"): lines.append("================================") lines.append(f" {payload['header']}") lines.append("================================") lines.append("") lines.extend(payload.get("lines", [])) lines.append("") if payload.get("footer"): lines.append("--------------------------------") lines.append(payload["footer"]) lines.append(f" {datetime.now().strftime('%c')}") result = await printer_print(lines) return {**result, "totalLines": len(lines)} @client.on_command("read_nfc_card") async def handle_read_nfc_card(payload): await display_show("Please tap your loyalty card...", "info") card = await nfc_read() await display_show(f"Welcome back, {card['memberName']}!", "success") return card @client.on_command("display_message") async def handle_display_message(payload): result = await display_show(payload["message"], payload.get("style", "info")) return {**result, "message": payload["message"]} async def main(): await client.connect() print("Retail kiosk online") await display_show("Welcome! How can I help you today?", "welcome") # Simulate a customer pressing the help button every few minutes while True: if random.random() < 0.1: await client.trigger("customer_help_requested", { "kiosk": "store-kiosk-01", "location": "entrance", "timestamp": datetime.now(timezone.utc).isoformat(), }) await asyncio.sleep(30) asyncio.run(main()) ``` ## Agent-Side Trigger Handler ```typescript theme={null} // src/triggers/customer-help.ts import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const customerHelp = defineDeviceTrigger({ name: 'customer-help-requested', description: 'Fired when a customer presses the help button on a retail kiosk', payloadSchema: z.object({ kiosk: z.string(), location: z.string(), timestamp: z.string(), }), execute: async (payload, { agent, device }) => { await agent.chat( `A customer at kiosk "${payload.kiosk}" (${payload.location}) ` + `is requesting help. Please display a helpful greeting and ` + `ask what they need assistance with.` ); }, }); ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; import { customerHelp } from './triggers/customer-help'; const retailSkill = new LuaSkill({ name: 'retail-kiosk', description: 'Retail kiosk customer service', context: ` You operate customer-facing retail kiosks. Device tools: - print_receipt: Print receipts. Format nicely with header, items, and footer. - read_nfc_card: Scan loyalty cards. Use when customer wants to check points or redeem rewards. - display_message: Show messages on screen. Use 'welcome' for greetings, 'success' for confirmations. Guidelines: - Always greet customers warmly - After reading a loyalty card, mention their tier and points - Include store name in receipt headers - Add return policy in receipt footers `, tools: [], }); export const agent = new LuaAgent({ name: 'retail-assistant', persona: `You are a friendly retail assistant at a kiosk. Help customers check loyalty points, print receipts, and find information. Be warm, concise, and helpful.`, skills: [retailSkill], deviceTriggers: [customerHelp], }); ``` ## Next Steps Upload screenshots and logs from kiosk devices Facilities management example Logistics and inventory management How device commands become agent tools # Smart Office Source: https://docs.heylua.ai/devices/examples/smart-office Build an AI facilities concierge with meeting room sensors, thermostats, and desk occupancy ## Overview **Industry:** Facilities Management A smart office system where the AI agent acts as a facilities concierge. It checks meeting room availability, adjusts climate controls, monitors desk occupancy, and sends notifications to office displays. **Commands:** * `check_meeting_rooms` -- Query occupancy sensors in all meeting rooms * `adjust_thermostat` -- Set target temperature for a zone * `desk_occupancy` -- Get desk utilization for a floor * `send_notification` -- Display a message on an office screen For Node.js, [provision a device credential](/devices/credentials#provision-a-device-credential) with `commands` for the exact agent and device name in this example. The Python example uses `api_key` with an existing non-dotted legacy key. This key remains supported indefinitely. ## Device Client ```typescript Node.js theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; // Simulated office hardware const meetingRooms = [ { name: 'Atlas', floor: 3, capacity: 8, occupied: true, bookedUntil: '14:30' }, { name: 'Everest', floor: 3, capacity: 12, occupied: false, bookedUntil: null }, { name: 'Summit', floor: 4, capacity: 6, occupied: true, bookedUntil: '15:00' }, { name: 'Pinnacle', floor: 4, capacity: 20, occupied: false, bookedUntil: null }, ]; const zones: Record = { 'floor-3': { currentTemp: 23.2, targetTemp: 22, mode: 'cooling' }, 'floor-4': { currentTemp: 21.8, targetTemp: 22, mode: 'idle' }, }; const desks: Record = { 'floor-3': { total: 48, occupied: 31 }, 'floor-4': { total: 52, occupied: 18 }, }; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'office-controller', group: 'building-systems', commands: [ { name: 'check_meeting_rooms', description: 'Check availability and occupancy of all meeting rooms. Returns room name, floor, capacity, and whether currently occupied.', inputSchema: { type: 'object', properties: { floor: { type: 'number', description: 'Filter by floor number (optional)' }, minCapacity: { type: 'number', description: 'Minimum room capacity (optional)' }, }, }, }, { name: 'adjust_thermostat', description: 'Adjust the target temperature for a building zone. Zones are identified by floor (e.g., floor-3, floor-4).', inputSchema: { type: 'object', properties: { zone: { type: 'string', description: 'Zone identifier (e.g., floor-3)' }, targetTemperature: { type: 'number', minimum: 18, maximum: 28, description: 'Target temperature in celsius' }, }, required: ['zone', 'targetTemperature'], }, }, { name: 'desk_occupancy', description: 'Get desk occupancy statistics for a given floor. Returns total desks, occupied desks, and utilization percentage.', inputSchema: { type: 'object', properties: { floor: { type: 'number', description: 'Floor number to check' }, }, required: ['floor'], }, }, { name: 'send_notification', description: 'Display a notification message on the office lobby or floor display screens', inputSchema: { type: 'object', properties: { message: { type: 'string', description: 'Message text to display' }, screen: { type: 'string', enum: ['lobby', 'floor-3', 'floor-4', 'all'], description: 'Target screen' }, priority: { type: 'string', enum: ['info', 'warning', 'urgent'], default: 'info' }, }, required: ['message', 'screen'], }, }, ], }); device.onCommand('check_meeting_rooms', async (payload) => { let rooms = meetingRooms; if (payload?.floor) { rooms = rooms.filter(r => r.floor === payload.floor); } if (payload?.minCapacity) { rooms = rooms.filter(r => r.capacity >= payload.minCapacity); } return { rooms: rooms.map(r => ({ ...r, available: !r.occupied, })), timestamp: new Date().toISOString(), }; }); device.onCommand('adjust_thermostat', async (payload) => { const zone = zones[payload.zone]; if (!zone) { throw new Error(`Unknown zone: ${payload.zone}`); } zone.targetTemp = payload.targetTemperature; zone.mode = zone.currentTemp > payload.targetTemperature ? 'cooling' : 'heating'; return { zone: payload.zone, currentTemperature: zone.currentTemp, targetTemperature: zone.targetTemp, mode: zone.mode, }; }); device.onCommand('desk_occupancy', async (payload) => { const floorKey = `floor-${payload.floor}`; const floor = desks[floorKey]; if (!floor) { throw new Error(`No data for floor ${payload.floor}`); } return { floor: payload.floor, totalDesks: floor.total, occupiedDesks: floor.occupied, availableDesks: floor.total - floor.occupied, utilization: Math.round((floor.occupied / floor.total) * 100), }; }); device.onCommand('send_notification', async (payload) => { console.log(`[DISPLAY ${payload.screen}] ${payload.priority?.toUpperCase() || 'INFO'}: ${payload.message}`); return { sent: true, screen: payload.screen, message: payload.message, timestamp: new Date().toISOString(), }; }); async function main() { await device.connect(); console.log('Office controller online'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition # Simulated office hardware meeting_rooms = [ {"name": "Atlas", "floor": 3, "capacity": 8, "occupied": True, "bookedUntil": "14:30"}, {"name": "Everest", "floor": 3, "capacity": 12, "occupied": False, "bookedUntil": None}, {"name": "Summit", "floor": 4, "capacity": 6, "occupied": True, "bookedUntil": "15:00"}, {"name": "Pinnacle", "floor": 4, "capacity": 20, "occupied": False, "bookedUntil": None}, ] zones = { "floor-3": {"currentTemp": 23.2, "targetTemp": 22, "mode": "cooling"}, "floor-4": {"currentTemp": 21.8, "targetTemp": 22, "mode": "idle"}, } desks = { "floor-3": {"total": 48, "occupied": 31}, "floor-4": {"total": 52, "occupied": 18}, } client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="office-controller", group="building-systems", commands=[ DeviceCommandDefinition( name="check_meeting_rooms", description="Check availability and occupancy of all meeting rooms. Returns room name, floor, capacity, and whether currently occupied.", input_schema={ "type": "object", "properties": { "floor": {"type": "number", "description": "Filter by floor number (optional)"}, "minCapacity": {"type": "number", "description": "Minimum room capacity (optional)"}, }, }, ), DeviceCommandDefinition( name="adjust_thermostat", description="Adjust the target temperature for a building zone. Zones are identified by floor (e.g., floor-3, floor-4).", input_schema={ "type": "object", "properties": { "zone": {"type": "string", "description": "Zone identifier (e.g., floor-3)"}, "targetTemperature": {"type": "number", "minimum": 18, "maximum": 28, "description": "Target temperature in celsius"}, }, "required": ["zone", "targetTemperature"], }, ), DeviceCommandDefinition( name="desk_occupancy", description="Get desk occupancy statistics for a given floor. Returns total desks, occupied desks, and utilization percentage.", input_schema={ "type": "object", "properties": { "floor": {"type": "number", "description": "Floor number to check"}, }, "required": ["floor"], }, ), DeviceCommandDefinition( name="send_notification", description="Display a notification message on the office lobby or floor display screens", input_schema={ "type": "object", "properties": { "message": {"type": "string", "description": "Message text to display"}, "screen": {"type": "string", "enum": ["lobby", "floor-3", "floor-4", "all"], "description": "Target screen"}, "priority": {"type": "string", "enum": ["info", "warning", "urgent"], "default": "info"}, }, "required": ["message", "screen"], }, ), ], ) @client.on_command("check_meeting_rooms") async def handle_check_meeting_rooms(payload): rooms = meeting_rooms if payload and payload.get("floor"): rooms = [r for r in rooms if r["floor"] == payload["floor"]] if payload and payload.get("minCapacity"): rooms = [r for r in rooms if r["capacity"] >= payload["minCapacity"]] return { "rooms": [{**r, "available": not r["occupied"]} for r in rooms], "timestamp": datetime.now(timezone.utc).isoformat(), } @client.on_command("adjust_thermostat") async def handle_adjust_thermostat(payload): zone = zones.get(payload["zone"]) if not zone: raise Exception(f"Unknown zone: {payload['zone']}") zone["targetTemp"] = payload["targetTemperature"] zone["mode"] = "cooling" if zone["currentTemp"] > payload["targetTemperature"] else "heating" return { "zone": payload["zone"], "currentTemperature": zone["currentTemp"], "targetTemperature": zone["targetTemp"], "mode": zone["mode"], } @client.on_command("desk_occupancy") async def handle_desk_occupancy(payload): floor_key = f"floor-{payload['floor']}" floor = desks.get(floor_key) if not floor: raise Exception(f"No data for floor {payload['floor']}") return { "floor": payload["floor"], "totalDesks": floor["total"], "occupiedDesks": floor["occupied"], "availableDesks": floor["total"] - floor["occupied"], "utilization": round((floor["occupied"] / floor["total"]) * 100), } @client.on_command("send_notification") async def handle_send_notification(payload): priority = (payload.get("priority") or "info").upper() print(f"[DISPLAY {payload['screen']}] {priority}: {payload['message']}") return { "sent": True, "screen": payload["screen"], "message": payload["message"], "timestamp": datetime.now(timezone.utc).isoformat(), } async def main(): await client.connect() print("Office controller online") asyncio.run(main()) ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; const facilitiesSkill = new LuaSkill({ name: 'facilities-concierge', description: 'Smart office management', context: ` You manage office facilities through connected devices. Device tools: - check_meeting_rooms: Use when someone needs a meeting room. Filter by floor or capacity. - adjust_thermostat: Use when someone reports temperature issues. Range: 18-28C. - desk_occupancy: Use to report floor utilization. - send_notification: Use to broadcast messages to office screens. Guidelines: - When someone needs a room, check availability and suggest the best match - For temperature complaints, check current temp first, then adjust - Only send urgent notifications for actual emergencies - Be friendly and helpful -- you are the office concierge `, tools: [], }); export const agent = new LuaAgent({ name: 'office-concierge', persona: `You are a friendly office concierge. You help employees find meeting rooms, manage comfort settings, and stay informed about office status. Be warm and helpful.`, skills: [facilitiesSkill], }); ``` ## Next Steps Logistics and inventory management Factory monitoring on Pico W Customer-facing kiosk with NFC and receipts How to write effective command definitions # Warehouse Scanner Source: https://docs.heylua.ai/devices/examples/warehouse-inventory Build an AI-powered warehouse inventory system with barcode scanners and weight sensors ## Overview **Industry:** Logistics / Warehousing A warehouse device that connects barcode scanners, weight scales, stock-level sensors, and gate controllers to an AI agent. The agent manages inventory, verifies shipments, and controls dock access through natural language. **Commands:** * `scan_barcode` -- Activate scanner, return barcode value * `read_weight` -- Read current weight on the scale * `check_stock_level` -- Query stock for a given SKU * `open_gate` -- Open a dock gate **Triggers:** * `low_stock_alert` -- Fires when stock drops below reorder threshold For Node.js, [provision a device credential](/devices/credentials#provision-a-device-credential) with `commands` and `triggers` for the exact agent and device name in this example. The Python example uses `api_key` with an existing non-dotted legacy key. This key remains supported indefinitely. ## Device Client ```typescript Node.js theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; // Simulated hardware interfaces const scanner = { scan: async () => ({ value: 'SKU-2024-0847', format: 'CODE128' }) }; const scale = { read: async () => ({ weight: 12.4, unit: 'kg' }) }; const inventory: Record = { 'SKU-2024-0847': { quantity: 8, threshold: 10, name: 'Widget A' }, 'SKU-2024-0312': { quantity: 142, threshold: 20, name: 'Bracket B' }, 'SKU-2024-1100': { quantity: 3, threshold: 15, name: 'Sensor C' }, }; const gate = { open: async () => ({ opened: true }) }; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'warehouse-station-01', group: 'warehouse-floor', commands: [ { name: 'scan_barcode', description: 'Activate the barcode scanner and return the scanned code. Takes 1-2 seconds. Returns barcode value and format.', timeoutMs: 10000, }, { name: 'read_weight', description: 'Read the current weight on the shipping scale in kilograms', }, { name: 'check_stock_level', description: 'Check current stock level for a given SKU', inputSchema: { type: 'object', properties: { sku: { type: 'string', description: 'Product SKU to check' }, }, required: ['sku'], }, }, { name: 'open_gate', description: 'Open the dock gate for incoming/outgoing shipments. Confirm with the user before opening.', inputSchema: { type: 'object', properties: { gate: { type: 'string', enum: ['dock-a', 'dock-b', 'dock-c'] }, }, required: ['gate'], }, timeoutMs: 15000, }, ], }); device.onCommand('scan_barcode', async () => { const result = await scanner.scan(); return { barcode: result.value, format: result.format, timestamp: new Date().toISOString() }; }); device.onCommand('read_weight', async () => { const result = await scale.read(); return { weight: result.weight, unit: result.unit }; }); device.onCommand('check_stock_level', async (payload) => { const item = inventory[payload.sku]; if (!item) { return { error: `Unknown SKU: ${payload.sku}`, found: false }; } return { sku: payload.sku, name: item.name, quantity: item.quantity, threshold: item.threshold, status: item.quantity <= item.threshold ? 'low' : 'ok', found: true, }; }); device.onCommand('open_gate', async (payload) => { const result = await gate.open(); return { gate: payload.gate, opened: result.opened, timestamp: new Date().toISOString() }; }); async function main() { await device.connect(); console.log('Warehouse station online'); // Monitor stock levels every 60 seconds setInterval(async () => { for (const [sku, item] of Object.entries(inventory)) { if (item.quantity <= item.threshold) { await device.trigger('low_stock_alert', { sku, name: item.name, currentQuantity: item.quantity, threshold: item.threshold, }); } } }, 60000); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition # Simulated hardware interfaces async def scan_barcode(): return {"value": "SKU-2024-0847", "format": "CODE128"} async def read_scale(): return {"weight": 12.4, "unit": "kg"} inventory = { "SKU-2024-0847": {"quantity": 8, "threshold": 10, "name": "Widget A"}, "SKU-2024-0312": {"quantity": 142, "threshold": 20, "name": "Bracket B"}, "SKU-2024-1100": {"quantity": 3, "threshold": 15, "name": "Sensor C"}, } async def open_gate_hw(): return {"opened": True} client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="warehouse-station-01", group="warehouse-floor", commands=[ DeviceCommandDefinition( name="scan_barcode", description="Activate the barcode scanner and return the scanned code. Takes 1-2 seconds. Returns barcode value and format.", timeout_ms=10000, ), DeviceCommandDefinition( name="read_weight", description="Read the current weight on the shipping scale in kilograms", ), DeviceCommandDefinition( name="check_stock_level", description="Check current stock level for a given SKU", input_schema={ "type": "object", "properties": { "sku": {"type": "string", "description": "Product SKU to check"}, }, "required": ["sku"], }, ), DeviceCommandDefinition( name="open_gate", description="Open the dock gate for incoming/outgoing shipments. Confirm with the user before opening.", input_schema={ "type": "object", "properties": { "gate": {"type": "string", "enum": ["dock-a", "dock-b", "dock-c"]}, }, "required": ["gate"], }, timeout_ms=15000, ), ], ) @client.on_command("scan_barcode") async def handle_scan_barcode(payload): result = await scan_barcode() return {"barcode": result["value"], "format": result["format"], "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("read_weight") async def handle_read_weight(payload): result = await read_scale() return {"weight": result["weight"], "unit": result["unit"]} @client.on_command("check_stock_level") async def handle_check_stock_level(payload): item = inventory.get(payload["sku"]) if not item: return {"error": f"Unknown SKU: {payload['sku']}", "found": False} return { "sku": payload["sku"], "name": item["name"], "quantity": item["quantity"], "threshold": item["threshold"], "status": "low" if item["quantity"] <= item["threshold"] else "ok", "found": True, } @client.on_command("open_gate") async def handle_open_gate(payload): result = await open_gate_hw() return {"gate": payload["gate"], "opened": result["opened"], "timestamp": datetime.now(timezone.utc).isoformat()} async def main(): await client.connect() print("Warehouse station online") # Monitor stock levels every 60 seconds while True: for sku, item in inventory.items(): if item["quantity"] <= item["threshold"]: await client.trigger("low_stock_alert", { "sku": sku, "name": item["name"], "currentQuantity": item["quantity"], "threshold": item["threshold"], }) await asyncio.sleep(60) asyncio.run(main()) ``` ## Agent-Side Trigger Handler ```typescript theme={null} // src/triggers/low-stock-alert.ts import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const lowStockAlert = defineDeviceTrigger({ name: 'low-stock-alert', description: 'Fired when warehouse stock for an item drops below its reorder threshold', payloadSchema: z.object({ sku: z.string(), name: z.string(), currentQuantity: z.number(), threshold: z.number(), }), execute: async (payload, { agent, device }) => { await agent.chat( `LOW STOCK ALERT from ${device.name}: ` + `"${payload.name}" (${payload.sku}) is at ${payload.currentQuantity} units, ` + `below the reorder threshold of ${payload.threshold}. ` + `Please create a purchase order to restock.` ); }, }); ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; import { lowStockAlert } from './triggers/low-stock-alert'; const warehouseSkill = new LuaSkill({ name: 'warehouse-operations', description: 'Warehouse inventory and shipping tools', context: ` This skill works with warehouse floor devices. Device tools (available when devices are connected): - scan_barcode: Use when a worker asks to scan or identify an item - read_weight: Use when verifying shipment weights - check_stock_level: Use when checking inventory for a SKU - open_gate: Use when a shipment needs dock access. ALWAYS confirm with the user first. Guidelines: - After scanning a barcode, automatically check its stock level - Compare shipment weights against expected values when available - Never open a gate without explicit user confirmation `, tools: [], }); export const agent = new LuaAgent({ name: 'warehouse-agent', persona: `You are a warehouse operations assistant. You help workers manage inventory, verify shipments, and control dock access. Be concise and action-oriented. If a device is offline, tell the worker and suggest they check the connection.`, skills: [warehouseSkill], deviceTriggers: [lowStockAlert], }); ``` ## Next Steps Facilities management example Factory monitoring on Pico W Deep dive into trigger architecture Full client reference # Windows Controller Source: https://docs.heylua.ai/devices/examples/windows-controller Control your Windows PC remotely through your AI agent ## Overview **Use case:** Personal computer automation via WhatsApp or web chat Turn your Windows PC into an AI-controllable device. Ask your agent to take screenshots, open apps, search files, manage processes, control volume, and more -- all from a chat message. **Commands:** * `take_screenshot` -- Capture the screen and return the image * `send_notification` -- Show a toast notification * `open_url` -- Open a URL in the default browser * `open_app` -- Launch an application * `search_files` -- Find files by name * `get_active_window` -- Get the current foreground window title * `system_info` -- Hostname, OS, CPU, RAM, uptime, battery * `get_clipboard` -- Read clipboard contents * `set_clipboard` -- Write text to the clipboard * `lock_screen` -- Lock the PC * `set_volume` -- Set the system volume (0--100) * `list_processes` -- List running processes with CPU usage * `kill_process` -- Kill a process by name **Prerequisites:** * [Node.js](https://nodejs.org/) 18+ (for the Node.js client) or Python 3.10+ (for the Python client) * PowerShell 5.1+ (included with Windows 10/11) * Optional: [BurntToast](https://github.com/Windos/BurntToast) PowerShell module for richer toast notifications (`Install-Module -Name BurntToast`) * Optional: [nircmd](https://www.nirsoft.net/utils/nircmd.html) for volume control (alternative to PowerShell) For Node.js, [provision a device credential](/devices/credentials#provision-a-device-credential) with `commands` for the exact agent and device name in this example. The Python example uses `api_key` with an existing non-dotted legacy key. This key remains supported indefinitely. ## Device Client ```typescript Node.js theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; import { execSync } from 'child_process'; import { readFileSync, unlinkSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; // Helper to run PowerShell commands function ps(command: string): string { return execSync(`powershell -NoProfile -Command "${command.replace(/"/g, '\\"')}"`) .toString() .trim(); } const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-windows-pc', group: 'personal-devices', commands: [ { name: 'take_screenshot', description: 'Capture a screenshot of the entire screen and return the image URL.', inputSchema: { type: 'object', properties: {} }, }, { name: 'send_notification', description: 'Show a Windows toast notification.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Notification title' }, message: { type: 'string', description: 'Notification body text' }, }, required: ['title', 'message'], }, }, { name: 'open_url', description: 'Open a URL in the default browser.', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The URL to open' }, }, required: ['url'], }, }, { name: 'open_app', description: 'Launch an application by name or path (e.g., "notepad", "calc", "C:\\Program Files\\App\\app.exe").', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Application name or path' }, }, required: ['name'], }, }, { name: 'search_files', description: 'Search for files by name. Searches common locations (Desktop, Documents, Downloads).', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Filename or wildcard pattern (e.g., "invoice.pdf", "*.xlsx")' }, path: { type: 'string', description: 'Directory to search (default: user home)' }, limit: { type: 'number', description: 'Max results (default 10)' }, }, required: ['query'], }, }, { name: 'get_active_window', description: 'Get the title of the currently focused window.', inputSchema: { type: 'object', properties: {} }, }, { name: 'system_info', description: 'Get system information: hostname, OS version, CPU, RAM, uptime, and battery level.', inputSchema: { type: 'object', properties: {} }, }, { name: 'get_clipboard', description: 'Read the current clipboard text contents.', inputSchema: { type: 'object', properties: {} }, }, { name: 'set_clipboard', description: 'Set the clipboard text contents.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to copy to clipboard' }, }, required: ['text'], }, }, { name: 'lock_screen', description: 'Lock the Windows PC immediately.', inputSchema: { type: 'object', properties: {} }, }, { name: 'set_volume', description: 'Set the system output volume.', inputSchema: { type: 'object', properties: { level: { type: 'number', minimum: 0, maximum: 100, description: 'Volume level 0-100' }, }, required: ['level'], }, }, { name: 'list_processes', description: 'List running processes sorted by CPU usage.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Max number of processes to return (default 10)' }, }, }, }, { name: 'kill_process', description: 'Kill a running process by name.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Process name (e.g., "notepad", "chrome")' }, }, required: ['name'], }, }, ], }); device.onCommand('take_screenshot', async () => { const filepath = join(tmpdir(), `screenshot-${Date.now()}.png`); ps(` Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height); $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size); $bitmap.Save('${filepath.replace(/\\/g, '\\\\')}'); $graphics.Dispose(); $bitmap.Dispose() `); const buffer = readFileSync(filepath); const url = await device.uploadFile(buffer, `screenshot-${Date.now()}.png`, 'image/png'); unlinkSync(filepath); return { imageUrl: url, timestamp: new Date().toISOString() }; }); device.onCommand('send_notification', async (payload) => { const title = payload.title.replace(/'/g, "''"); const message = payload.message.replace(/'/g, "''"); try { ps(`New-BurntToastNotification -Text '${title}', '${message}'`); } catch { // Fallback if BurntToast is not installed ps(` [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; $xml = '${title}${message}'; $toast = [Windows.UI.Notifications.ToastNotification]::new([Windows.Data.Xml.Dom.XmlDocument]::new()); $toast.Content.LoadXml($xml); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Lua Agent').Show($toast) `); } return { sent: true, title: payload.title, message: payload.message }; }); device.onCommand('open_url', async (payload) => { execSync(`start "" "${payload.url}"`); return { opened: true, url: payload.url }; }); device.onCommand('open_app', async (payload) => { ps(`Start-Process '${payload.name}'`); return { opened: true, app: payload.name }; }); device.onCommand('search_files', async (payload) => { const limit = payload.limit || 10; const searchPath = payload.path || '$env:USERPROFILE'; const raw = ps(` Get-ChildItem -Path ${searchPath} -Recurse -Filter '${payload.query}' -ErrorAction SilentlyContinue | Select-Object -First ${limit} -ExpandProperty FullName `); const files = raw ? raw.split('\r\n').filter(Boolean) : []; return { query: payload.query, results: files, count: files.length }; }); device.onCommand('get_active_window', async () => { const title = ps(` Add-Type -Name Win32 -Namespace Native -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);'; $sb = New-Object System.Text.StringBuilder(256); [Native.Win32]::GetWindowText([Native.Win32]::GetForegroundWindow(), $sb, 256) | Out-Null; $sb.ToString() `); return { activeWindow: title }; }); device.onCommand('system_info', async () => { const info = ps(` $os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor).Name; $ramGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1); $uptime = (Get-Date) - $os.LastBootUpTime; $battery = (Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue).EstimatedChargeRemaining; @{ hostname = $env:COMPUTERNAME; os = $os.Caption; cpu = $cpu; memoryGB = $ramGB; uptime = '{0}d {1}h {2}m' -f $uptime.Days, $uptime.Hours, $uptime.Minutes; battery = if ($battery) { "$battery%" } else { 'N/A (desktop)' } } | ConvertTo-Json `); return JSON.parse(info); }); device.onCommand('get_clipboard', async () => { const text = ps('Get-Clipboard'); return { clipboard: text }; }); device.onCommand('set_clipboard', async (payload) => { const escaped = payload.text.replace(/'/g, "''"); ps(`Set-Clipboard -Value '${escaped}'`); return { set: true, text: payload.text }; }); device.onCommand('lock_screen', async () => { execSync('rundll32.exe user32.dll,LockWorkStation'); return { locked: true, timestamp: new Date().toISOString() }; }); device.onCommand('set_volume', async (payload) => { ps(` $wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object { $wsh.SendKeys([char]174) }; $steps = [math]::Round(${payload.level} / 2); 1..$steps | ForEach-Object { $wsh.SendKeys([char]175) } `); return { volume: payload.level }; }); device.onCommand('list_processes', async (payload) => { const limit = payload?.limit || 10; const raw = ps(` Get-Process | Sort-Object CPU -Descending | Select-Object -First ${limit} Name, Id, @{N='CPU_Seconds';E={[math]::Round($_.CPU,1)}}, @{N='Memory_MB';E={[math]::Round($_.WorkingSet64/1MB,1)}} | ConvertTo-Json `); return { processes: JSON.parse(raw) }; }); device.onCommand('kill_process', async (payload) => { const name = payload.name.replace(/'/g, "''"); ps(`Stop-Process -Name '${name}' -Force -ErrorAction SilentlyContinue`); return { killed: true, process: payload.name }; }); async function main() { await device.connect(); console.log('Windows controller online'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import json import os import subprocess import tempfile from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition def ps(command: str) -> str: """Run a PowerShell command and return its output.""" result = subprocess.run( ["powershell", "-NoProfile", "-Command", command], capture_output=True, text=True, ) return result.stdout.strip() client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="my-windows-pc", group="personal-devices", commands=[ DeviceCommandDefinition( name="take_screenshot", description="Capture a screenshot of the entire screen and return the image URL.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="send_notification", description="Show a Windows toast notification.", input_schema={ "type": "object", "properties": { "title": {"type": "string", "description": "Notification title"}, "message": {"type": "string", "description": "Notification body text"}, }, "required": ["title", "message"], }, ), DeviceCommandDefinition( name="open_url", description="Open a URL in the default browser.", input_schema={ "type": "object", "properties": { "url": {"type": "string", "description": "The URL to open"}, }, "required": ["url"], }, ), DeviceCommandDefinition( name="open_app", description="Launch an application by name or path (e.g., 'notepad', 'calc').", input_schema={ "type": "object", "properties": { "name": {"type": "string", "description": "Application name or path"}, }, "required": ["name"], }, ), DeviceCommandDefinition( name="search_files", description="Search for files by name. Searches common locations (Desktop, Documents, Downloads).", input_schema={ "type": "object", "properties": { "query": {"type": "string", "description": "Filename or wildcard pattern (e.g., 'invoice.pdf', '*.xlsx')"}, "path": {"type": "string", "description": "Directory to search (default: user home)"}, "limit": {"type": "number", "description": "Max results (default 10)"}, }, "required": ["query"], }, ), DeviceCommandDefinition( name="get_active_window", description="Get the title of the currently focused window.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="system_info", description="Get system information: hostname, OS version, CPU, RAM, uptime, and battery level.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="get_clipboard", description="Read the current clipboard text contents.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="set_clipboard", description="Set the clipboard text contents.", input_schema={ "type": "object", "properties": { "text": {"type": "string", "description": "Text to copy to clipboard"}, }, "required": ["text"], }, ), DeviceCommandDefinition( name="lock_screen", description="Lock the Windows PC immediately.", input_schema={"type": "object", "properties": {}}, ), DeviceCommandDefinition( name="set_volume", description="Set the system output volume.", input_schema={ "type": "object", "properties": { "level": {"type": "number", "minimum": 0, "maximum": 100, "description": "Volume level 0-100"}, }, "required": ["level"], }, ), DeviceCommandDefinition( name="list_processes", description="List running processes sorted by CPU usage.", input_schema={ "type": "object", "properties": { "limit": {"type": "number", "description": "Max number of processes to return (default 10)"}, }, }, ), DeviceCommandDefinition( name="kill_process", description="Kill a running process by name.", input_schema={ "type": "object", "properties": { "name": {"type": "string", "description": "Process name (e.g., 'notepad', 'chrome')"}, }, "required": ["name"], }, ), ], ) @client.on_command("take_screenshot") async def handle_take_screenshot(payload): filepath = os.path.join(tempfile.gettempdir(), f"screenshot-{int(datetime.now().timestamp())}.png") ps(f""" Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height); $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size); $bitmap.Save('{filepath}'); $graphics.Dispose(); $bitmap.Dispose() """) with open(filepath, "rb") as f: buffer = f.read() url = await client.upload_file(buffer, f"screenshot-{int(datetime.now().timestamp())}.png", "image/png") os.unlink(filepath) return {"imageUrl": url, "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("send_notification") async def handle_send_notification(payload): title = payload["title"].replace("'", "''") message = payload["message"].replace("'", "''") try: ps(f"New-BurntToastNotification -Text '{title}', '{message}'") except Exception: ps(f""" [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; $xml = '{title}{message}'; $toast = [Windows.UI.Notifications.ToastNotification]::new([Windows.Data.Xml.Dom.XmlDocument]::new()); $toast.Content.LoadXml($xml); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Lua Agent').Show($toast) """) return {"sent": True, "title": payload["title"], "message": payload["message"]} @client.on_command("open_url") async def handle_open_url(payload): subprocess.run(["start", "", payload["url"]], shell=True, check=True) return {"opened": True, "url": payload["url"]} @client.on_command("open_app") async def handle_open_app(payload): ps(f"Start-Process '{payload['name']}'") return {"opened": True, "app": payload["name"]} @client.on_command("search_files") async def handle_search_files(payload): limit = payload.get("limit", 10) search_path = payload.get("path", "$env:USERPROFILE") query = payload["query"].replace("'", "''") raw = ps(f""" Get-ChildItem -Path {search_path} -Recurse -Filter '{query}' -ErrorAction SilentlyContinue | Select-Object -First {limit} -ExpandProperty FullName """) files = [f for f in raw.split("\r\n") if f] if raw else [] return {"query": payload["query"], "results": files, "count": len(files)} @client.on_command("get_active_window") async def handle_get_active_window(payload): title = ps(""" Add-Type -Name Win32 -Namespace Native -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);'; $sb = New-Object System.Text.StringBuilder(256); [Native.Win32]::GetWindowText([Native.Win32]::GetForegroundWindow(), $sb, 256) | Out-Null; $sb.ToString() """) return {"activeWindow": title} @client.on_command("system_info") async def handle_system_info(payload): raw = ps(""" $os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor).Name; $ramGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1); $uptime = (Get-Date) - $os.LastBootUpTime; $battery = (Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue).EstimatedChargeRemaining; @{ hostname = $env:COMPUTERNAME; os = $os.Caption; cpu = $cpu; memoryGB = $ramGB; uptime = '{0}d {1}h {2}m' -f $uptime.Days, $uptime.Hours, $uptime.Minutes; battery = if ($battery) { "$battery%" } else { 'N/A (desktop)' } } | ConvertTo-Json """) return json.loads(raw) @client.on_command("get_clipboard") async def handle_get_clipboard(payload): text = ps("Get-Clipboard") return {"clipboard": text} @client.on_command("set_clipboard") async def handle_set_clipboard(payload): escaped = payload["text"].replace("'", "''") ps(f"Set-Clipboard -Value '{escaped}'") return {"set": True, "text": payload["text"]} @client.on_command("lock_screen") async def handle_lock_screen(payload): subprocess.run(["rundll32.exe", "user32.dll,LockWorkStation"], check=True) return {"locked": True, "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("set_volume") async def handle_set_volume(payload): ps(f""" $wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object {{ $wsh.SendKeys([char]174) }}; $steps = [math]::Round({payload['level']} / 2); 1..$steps | ForEach-Object {{ $wsh.SendKeys([char]175) }} """) return {"volume": payload["level"]} @client.on_command("list_processes") async def handle_list_processes(payload): limit = payload.get("limit", 10) if payload else 10 raw = ps(f""" Get-Process | Sort-Object CPU -Descending | Select-Object -First {limit} Name, Id, @{{N='CPU_Seconds';E={{[math]::Round($_.CPU,1)}}}}, @{{N='Memory_MB';E={{[math]::Round($_.WorkingSet64/1MB,1)}}}} | ConvertTo-Json """) return {"processes": json.loads(raw)} @client.on_command("kill_process") async def handle_kill_process(payload): name = payload["name"].replace("'", "''") ps(f"Stop-Process -Name '{name}' -Force -ErrorAction SilentlyContinue") return {"killed": True, "process": payload["name"]} async def main(): await client.connect() print("Windows controller online") asyncio.run(main()) ``` ## Agent Configuration ```typescript theme={null} // src/index.ts import { LuaAgent, LuaSkill } from 'lua-cli'; const windowsControlSkill = new LuaSkill({ name: 'windows-controller', description: 'Control a Windows PC remotely', context: ` You are a personal assistant that controls a Windows PC. You can take screenshots, open apps, search files, manage processes, control volume, and more. Device tools: - take_screenshot: Captures the screen and returns an image URL. - send_notification: Shows a Windows toast notification with a title and message. - open_url: Opens a URL in the default browser. - open_app: Launches an application by name or path. - search_files: Searches for files by name across user directories. - get_active_window: Reports the title of the currently focused window. - system_info: Returns hostname, OS, CPU, RAM, uptime, and battery status. - get_clipboard: Reads the current clipboard text. - set_clipboard: Sets the clipboard to the provided text. - lock_screen: Locks the PC immediately. - set_volume: Sets the system volume from 0 to 100. - list_processes: Lists running processes sorted by CPU usage. - kill_process: Kills a process by name. Use with caution. Guidelines: - When asked for a screenshot, take it and share the image URL - For file searches, show the full paths in the results - Confirm destructive actions (locking the screen, killing processes) before executing - Be conversational and helpful `, tools: [], }); export const agent = new LuaAgent({ name: 'windows-assistant', persona: `You are a personal assistant that controls a Windows PC. You can take screenshots, open apps, search files, manage processes, control volume, and automate tasks. Be helpful, concise, and confirm before taking potentially disruptive actions like locking the screen or killing processes.`, skills: [windowsControlSkill], }); ``` ## What You Can Ask Here are real conversational examples you can send from WhatsApp or web chat: "Take a screenshot and send it to me" "Open Notepad" / "Launch Excel" "Search for files called invoice.pdf" "What window do I have open right now?" "Lock my PC" "Set the volume to 30%" "What processes are using the most CPU?" "Kill the Notepad process" "Copy this text to my clipboard: Meeting at 3pm" "Send me a notification that says Stand up and stretch" "Open github.com in my browser" "How much RAM do I have? What's my uptime?" **Security note:** This device client gives your AI agent direct control over your computer. Only run it on machines you trust, and consider limiting which commands are registered based on your comfort level. ## Next Steps Control a Mac the same way Office automation with sensors and displays How screenshot uploads work How to write effective command definitions # How It Works Source: https://docs.heylua.ai/devices/how-it-works Architecture of the Lua Device Gateway ## Architecture Overview The Device Gateway sits between your physical devices and the Lua agent runtime. It handles authentication, connection management, command routing, and trigger delivery. ```mermaid theme={null} graph LR subgraph Devices D1["🖥️ Node.js Device
(Socket.IO)"] D2["📡 Pico W / IoT
(MQTT)"] end subgraph Lua Platform GW["🔌 Device Gateway
Auth · Routing · Heartbeat"] AG["🤖 AI Agent
Skills · Tools · Triggers"] end subgraph Users U1["💬 WhatsApp"] U2["🌐 Web Chat"] end D1 <-->|WebSocket| GW D2 <-->|MQTT| GW GW <-->|Commands & Responses| AG AG <-->|Conversation| U1 AG <-->|Conversation| U2 ``` ## Self-Describing Flow When a device connects, it does not need any server-side configuration. The device tells the gateway what it can do, and the gateway tells the agent. The device client sends its `commands` array during the Socket.IO auth handshake or MQTT online status message. Each command includes a name, description, and optional JSON Schema for input parameters. The gateway validates the device credential, its exact agent and device binding, and the permitted operations. It then registers the device and stores the permitted command manifest. When the agent processes a user message, it queries connected devices and merges their commands into the tool list. Each device command appears as a tool named `device__{deviceName}__{commandName}`. When a device goes offline (intentional disconnect, network loss, or missed heartbeats), its tools are removed from the agent. No stale tools remain. ## Command Delivery Path When the agent decides to use a device tool, the command flows through the gateway: ```mermaid theme={null} sequenceDiagram participant User as 💬 User participant Agent as 🤖 Agent participant GW as 🔌 Gateway participant Device as 📡 Device User->>Agent: "Read the temperature" Agent->>Agent: Selects tool: device__pico_sensor__read_temperature Agent->>GW: Send command (commandId, payload) GW->>Device: Deliver command via WebSocket/MQTT Device->>Device: Execute handler Device->>GW: Return response GW->>Agent: Deliver response Agent->>User: "The temperature is 23.5°C" ``` Each command carries a unique `commandId` for idempotency. If the device receives the same command twice (due to retry or redelivery), it returns the cached response. ## Trigger Flow Triggers go in the opposite direction — from device to agent: ```mermaid theme={null} sequenceDiagram participant Device as 📡 Device participant GW as 🔌 Gateway participant Agent as 🤖 Agent participant User as 💬 User Device->>Device: Detects temperature > 40°C Device->>GW: trigger('high_temp', { temperature: 42.1 }) GW->>Device: ACK (triggerId, received: true) GW->>Agent: Deliver trigger to handler Agent->>Agent: Runs defineDeviceTrigger execute() Agent->>User: "⚠️ Temperature alert: 42.1°C!" ``` Triggers are **fire-and-forget** from the device's perspective. The device gets an acknowledgment that the gateway received the trigger, but does not wait for the agent to finish processing it. ## Transport Comparison | Feature | Socket.IO | MQTT | | ---------------- | -------------------------- | ------------------------------------------------- | | Protocol | WebSocket over HTTPS | MQTT 3.1.1 over TLS | | Default URL | `https://api.heylua.ai` | `wss://mqtt.heylua.ai/mqtt` | | Reconnection | Built-in with jitter | Built-in with backoff | | Message ordering | Guaranteed (single socket) | Guaranteed per topic (QoS 1) | | Offline queueing | No | Yes (persistent session) | | Last Will (LWT) | Not applicable | Automatic offline status on disconnect | | Best for | Node.js, desktops, servers | Microcontrollers, battery devices, flaky networks | | RAM footprint | \~10 MB (Node.js) | \~30 KB (MicroPython on Pico W) | ## Security Model New installations use a credential bound to one agent, one device name, and selected device operations. Existing non-dotted legacy keys remain supported indefinitely. A device can only interact with the agent it authenticates against. Cross-agent communication is not possible. All transports use TLS. Socket.IO connects over HTTPS. MQTT connects over port 443 (WebSocket) with TLS. The gateway expects a heartbeat every 30 seconds. Missed heartbeats trigger disconnect detection. MQTT additionally uses Last Will and Testament (LWT) for instant offline notification. ## Connection Lifecycle ``` 1. Client opens WebSocket to https://api.heylua.ai/devices 2. Auth handshake: { apiKey: deviceCredential, agentId, deviceName, commands[] } 3. Server validates the credential binding and permitted operations, then emits 'connected' 4. Client starts heartbeat (every 30s) 5. Bidirectional command/trigger exchange 6. On disconnect: auto-reconnect with jittered exponential backoff 7. On reconnect: re-sends auth (commands may have changed) 8. On SIGTERM/SIGINT: graceful disconnect, no reconnect ``` ``` 1. Client connects to wss://mqtt.heylua.ai/mqtt - username: {agentId}:{deviceName} - password: {deviceCredential} - LWT: offline status (retained) 2. Client subscribes to: command, connected, trigger_ack, error 3. Client publishes online status (retained, no secrets) 4. Client publishes commands without the typed credential (non-retained) 5. Client starts heartbeat (every 30s) 6. Bidirectional command/trigger exchange via topics 7. On disconnect: broker publishes LWT, server detects offline 8. On reconnect: re-subscribe, re-publish online status ``` The Socket.IO `auth.apiKey` property and MQTT password remain the protocol fields for both device credentials and existing legacy keys. The Node.js client uses `deviceCredential` for new provisioning. Published Python and MicroPython clients continue to use `api_key` with existing legacy keys. See [Device credentials](/devices/credentials). ## Next Steps Deep dive into how devices declare their capabilities Understand device-to-agent event flow Configure MQTT for constrained devices How device commands become agent tools # MicroPython Client Source: https://docs.heylua.ai/devices/micropython-client Connect a Raspberry Pi Pico W or any MicroPython board to your Lua agent ## Overview The MicroPython client (`lua_device.py`) runs on microcontrollers with as little as 264KB of RAM. It uses MQTT natively and supports the full device protocol: commands, triggers, heartbeats, reconnection, and idempotency dedup. ## Hardware Requirements | Component | Minimum | Recommended | | ----------- | ------------------- | ------------------------------- | | Board | Raspberry Pi Pico W | Any MicroPython board with WiFi | | RAM | 264 KB | 512 KB+ | | Flash | 2 MB | 2 MB | | MicroPython | v1.20+ | Latest stable | | Network | WiFi (2.4 GHz) | WiFi with stable connection | The client depends on `umqtt.robust` (preferred) or `umqtt.simple`, both included in standard MicroPython firmware for the Pico W. ## Setup The current MicroPython distribution uses `api_key` with an existing non-dotted legacy key. This configuration remains supported indefinitely. The client does not expose the typed `device_credential` option. Download the latest MicroPython UF2 from [micropython.org](https://micropython.org/download/RPI_PICO_W/) and flash it to your Pico W. Copy `lua_device.py` to your Pico W. You can use Thonny, `mpremote`, or `rshell`: ```bash theme={null} mpremote cp lua_device.py :lua_device.py ``` Create a `main.py` that connects to WiFi before creating the device: ```python theme={null} import network import time wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect("YOUR_SSID", "YOUR_PASSWORD") while not wlan.isconnected(): time.sleep(0.5) print("WiFi connected:", wlan.ifconfig()[0]) ``` Add the device setup after WiFi connection: ```python theme={null} from lua_device import LuaDevice device = LuaDevice( agent_id="your-agent-id", api_key="your-api-key", device_name="pico-sensor", server="mqtt.heylua.ai", ) ``` Register command handlers and start the main loop: ```python theme={null} @device.command("read_sensor") def read_sensor(payload): return {"temperature": 22.5, "humidity": 60} device.connect() device.run() # blocks forever, handles commands ``` ## LuaDevice Class Reference ### Constructor Create the client with keyword arguments. Replace each placeholder value: ```python theme={null} LuaDevice( agent_id="your-agent-id", api_key="your-api-key", device_name="your-device-name", server="mqtt.heylua.ai", port=443, group=None, use_ssl=True, ) ``` | Parameter | Type | Default | Description | | ------------- | ---- | -------- | ------------------------------------------------------ | | `agent_id` | str | required | Agent ID to connect to | | `api_key` | str | required | Existing non-dotted legacy key, supported indefinitely | | `device_name` | str | required | Unique name for this device | | `server` | str | required | MQTT broker hostname (e.g., `mqtt.heylua.ai`) | | `port` | int | `443` | MQTT broker port | | `group` | str | `None` | Optional device group name | | `use_ssl` | bool | `True` | Enable TLS encryption | ### Methods | Method | Description | | ----------------------------- | -------------------------------------------------------------------- | | `connect()` | Connect to the MQTT broker. Sets up LWT and subscriptions. | | `disconnect()` | Gracefully disconnect. Publishes offline status first. | | `run(check_interval_ms=100)` | Main loop. Blocks forever, checks for messages and sends heartbeats. | | `trigger(name, payload=None)` | Fire a trigger event to the agent. | | `on_command(name, handler)` | Register a command handler (non-decorator style). | ### The `@device.command` Decorator The preferred way to register command handlers: ```python theme={null} @device.command("led_on") def led_on(payload): # payload is a dict with whatever the agent sent pin = machine.Pin("LED", machine.Pin.OUT) pin.on() return {"status": "on"} ``` The handler receives a `payload` dict and must return a dict (or `None`). If the handler raises an exception, the error message is sent back to the agent. ## Complete Example: LED + DHT22 A Pico W that controls an onboard LED and reads a DHT22 temperature/humidity sensor: ```python theme={null} import network import machine import time import dht # -- WiFi -- wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect("YOUR_SSID", "YOUR_PASSWORD") while not wlan.isconnected(): time.sleep(0.5) print("WiFi connected:", wlan.ifconfig()[0]) # -- Sensor setup -- led = machine.Pin("LED", machine.Pin.OUT) dht_sensor = dht.DHT22(machine.Pin(15)) # -- Device -- from lua_device import LuaDevice device = LuaDevice( agent_id="your-agent-id", api_key="your-api-key", device_name="pico-env-sensor", server="mqtt.heylua.ai", group="office-sensors", ) @device.command("led_on") def led_on(payload): led.on() return {"status": "on"} @device.command("led_off") def led_off(payload): led.off() return {"status": "off"} @device.command("read_environment") def read_environment(payload): dht_sensor.measure() return { "temperature": dht_sensor.temperature(), "humidity": dht_sensor.humidity(), "led": "on" if led.value() else "off", } @device.command("blink") def blink(payload): count = payload.get("count", 3) delay = payload.get("delay_ms", 200) for _ in range(count): led.on() time.sleep_ms(delay) led.off() time.sleep_ms(delay) return {"blinked": count} # -- Connect and run -- device.connect() # Optional: fire a trigger every 60s if temperature is high last_check = time.time() while True: try: device._client.check_msg() now = time.time() if now - device._last_heartbeat >= device._heartbeat_interval: device._client.publish(device._topic_prefix + "heartbeat", b"", qos=0) device._last_heartbeat = now # Periodic temperature check if now - last_check >= 60: dht_sensor.measure() temp = dht_sensor.temperature() if temp > 30: device.trigger("high_temperature", { "temperature": temp, "threshold": 30, }) last_check = now time.sleep_ms(100) except OSError as e: print("Connection lost:", e) device._reconnect() except Exception as e: print("Error:", e) time.sleep(1) ``` ## Troubleshooting The Pico W cannot reach the MQTT broker. Check: * WiFi is connected (`wlan.isconnected()` returns `True`) * DNS resolution works (try `socket.getaddrinfo("mqtt.heylua.ai", 443)`) * No firewall blocking port 443 (WebSocket) Authentication failed. Verify: * `agent_id` matches your agent exactly * `api_key` is valid and not expired * `device_name` is 1–200 characters and contains no whitespace, `/`, `+`, or `#` The Pico W has limited RAM. Try: * Reduce `_dedup_ttl` (default 300 seconds) if you are processing many commands * Avoid large payloads in command responses * Use `gc.collect()` periodically in your main loop * Compile `lua_device.py` to `.mpy` bytecode with `mpy-cross` to save RAM Ensure: * `device.connect()` completed without error * You are calling `device.run()` or manually calling `device._client.check_msg()` in a loop * The command name in your handler matches the name the agent is using Some MicroPython builds have limited TLS support. Try: * Update to the latest MicroPython firmware * Set `use_ssl=False` temporarily for debugging (not recommended in production) ## Next Steps Step-by-step hardware setup with photos and wiring Complete factory monitoring example on Pico W MQTT topic structure and QoS details Send events from your device to the agent # MQTT Transport Source: https://docs.heylua.ai/devices/mqtt-client Configure MQTT for constrained devices and unreliable networks ## When to Use MQTT Use MQTT instead of the default Socket.IO transport when: * Your device has limited RAM (microcontrollers, Pico W) * The network connection is unreliable or intermittent * You need offline message queueing (commands delivered when the device reconnects) * You are already running an MQTT infrastructure * The device is battery-powered and needs a lightweight protocol The Lua Device Gateway runs an MQTT broker at `wss://mqtt.heylua.ai/mqtt`. You do not need to run your own broker. ## Configuration For a new Node.js installation, [provision a device credential](/devices/credentials#provision-a-device-credential) for the exact agent, device name, and operations that the device uses. The published Python 1.3.0 client and the current MicroPython distribution continue to use `api_key` with existing legacy keys. ### Node.js ```typescript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: 'your-agent-id', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'pico-sensor-01', transport: 'mqtt', mqttUrl: 'wss://mqtt.heylua.ai/mqtt', // default, can be omitted commands: [ { name: 'read_sensor', description: 'Read temperature and humidity' }, ], }); device.onCommand('read_sensor', async () => { return { temperature: 22.5, humidity: 60 }; }); await device.connect(); ``` ### Python ```python theme={null} import asyncio import os from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id="your-agent-id", api_key=os.environ["LUA_API_KEY"], device_name="pico-sensor-01", transport="mqtt", mqtt_url="wss://mqtt.heylua.ai/mqtt", # default, can be omitted commands=[ DeviceCommandDefinition(name="read_sensor", description="Read temperature and humidity"), ], ) @client.on_command("read_sensor") async def handle_read_sensor(payload): return {"temperature": 22.5, "humidity": 60} asyncio.run(client.connect()) ``` ### MicroPython ```python theme={null} from lua_device import LuaDevice device = LuaDevice( agent_id="your-agent-id", api_key="your-api-key", device_name="pico-sensor-01", server="mqtt.heylua.ai", port=443, ) @device.command("read_sensor") def read_sensor(payload): return {"temperature": 22.5, "humidity": 60} device.connect() device.run() # blocks, listens for commands ``` ## Topic Structure All MQTT topics follow a consistent prefix pattern: ``` lua/devices/{agentId}/{deviceName}/{suffix} ``` | Topic Suffix | Direction | QoS | Retained | Purpose | | ---------------- | ---------------- | --- | -------- | ------------------------------------------------------- | | `status` | Device publish | 1 | Yes | Online/offline status (retained for broker persistence) | | `heartbeat` | Device publish | 0 | No | Keepalive signal every 30 seconds | | `response` | Device publish | 1 | No | Command execution results | | `trigger` | Device publish | 1 | No | Device-to-agent event triggers | | `command` | Device subscribe | 1 | No | Incoming commands from the agent | | `connected` | Device subscribe | 1 | No | Server connection confirmation | | `trigger_ack` | Device subscribe | 1 | No | Trigger receipt acknowledgment | | `trigger_result` | Device subscribe | 1 | No | Trigger execution results (optional) | | `error` | Device subscribe | 1 | No | Server-side error messages | | `pong` | Device subscribe | 0 | No | Heartbeat response | ## Last Will and Testament (LWT) The MQTT client automatically sets a Last Will and Testament message on the `status` topic. If the device disconnects unexpectedly (network failure, power loss), the broker publishes the LWT message, which the gateway uses to immediately mark the device as offline. ```json theme={null} { "status": "offline", "timestamp": "2025-01-15T10:30:00.000Z" } ``` This is more reliable than heartbeat-based detection alone, since the broker publishes the LWT within seconds of losing the TCP connection. ## QoS Levels | QoS | Meaning | Used For | | --- | ----------------------------------- | --------------------------------------------------------- | | 0 | At most once (fire-and-forget) | Heartbeats -- acceptable to miss one | | 1 | At least once (with acknowledgment) | Commands, responses, triggers, status -- must not be lost | The device client uses QoS 1 for all messages except heartbeats. Combined with persistent sessions (`clean: false`), this means commands are queued by the broker when the device is temporarily offline and delivered when it reconnects. The idempotency dedup layer on the device (LRU cache of recent `commandId` values) ensures that redelivered QoS 1 messages do not cause duplicate command execution. ## Authentication MQTT authentication uses the `username` and `password` fields of the MQTT CONNECT packet: | Field | Value | | ---------- | ----------------------------------------------------------------------------------------------------------- | | `clientId` | `lua-{agentId}-{deviceName}` | | `username` | `{agentId}:{deviceName}` | | `password` | Node.js/custom clients: device credential. Published Python/MicroPython clients: existing legacy `api_key`. | For a typed device credential, the CONNECT password is the only MQTT message that contains the secret. The client sends its command manifest in a separate non-retained status message without the credential. Retained status messages never contain a secret. Existing clients that use a non-dotted legacy key keep their current behavior. They send the key as the CONNECT password and in the existing non-retained `apiKey` status field. See [Device credentials](/devices/credentials#preserve-existing-installations). ## Complete Example A humidity and temperature monitor that fires an alert trigger when conditions are out of range: ```typescript TypeScript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'greenhouse-sensor', transport: 'mqtt', group: 'greenhouse-sensors', commands: [ { name: 'read_environment', description: 'Read temperature, humidity, and soil moisture', }, { name: 'toggle_irrigation', description: 'Turn irrigation on or off', inputSchema: { type: 'object', properties: { enabled: { type: 'boolean' }, }, required: ['enabled'], }, }, ], }); let irrigationOn = false; device.onCommand('read_environment', async () => { return { temperature: 28.3, humidity: 65, soilMoisture: 42, irrigationOn, timestamp: new Date().toISOString(), }; }); device.onCommand('toggle_irrigation', async (payload) => { irrigationOn = payload.enabled; return { irrigationOn }; }); async function main() { device.on('connected', () => console.log('Sensor online (MQTT)')); device.on('reconnected', () => console.log('Sensor reconnected')); device.on('error', (err) => console.error('MQTT error:', err)); await device.connect(); // Periodically check conditions and fire triggers setInterval(async () => { const humidity = 65 + Math.random() * 20; if (humidity > 80) { await device.trigger('humidity_alert', { humidity: Math.round(humidity), threshold: 80, sensor: 'greenhouse-sensor', }); } }, 10000); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os import random from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition irrigation_on = False client = DeviceClient( agent_id=os.environ["LUA_AGENT_ID"], api_key=os.environ["LUA_API_KEY"], device_name="greenhouse-sensor", transport="mqtt", group="greenhouse-sensors", commands=[ DeviceCommandDefinition( name="read_environment", description="Read temperature, humidity, and soil moisture", ), DeviceCommandDefinition( name="toggle_irrigation", description="Turn irrigation on or off", input_schema={ "type": "object", "properties": { "enabled": {"type": "boolean"}, }, "required": ["enabled"], }, ), ], ) @client.on_command("read_environment") async def handle_read_environment(payload): return { "temperature": 28.3, "humidity": 65, "soilMoisture": 42, "irrigationOn": irrigation_on, "timestamp": datetime.now(timezone.utc).isoformat(), } @client.on_command("toggle_irrigation") async def handle_toggle_irrigation(payload): global irrigation_on irrigation_on = payload["enabled"] return {"irrigationOn": irrigation_on} async def main(): await client.connect() print("Sensor online (MQTT)") # Periodically check conditions and fire triggers while True: humidity = 65 + random.random() * 20 if humidity > 80: await client.trigger("humidity_alert", { "humidity": round(humidity), "threshold": 80, "sensor": "greenhouse-sensor", }) await asyncio.sleep(10) asyncio.run(main()) ``` ## Next Steps Run on a Raspberry Pi Pico W with native MQTT Step-by-step hardware setup with Thonny Socket.IO transport for full-featured Node.js devices Understand the full transport comparison # Node.js Client Source: https://docs.heylua.ai/devices/node-client Complete reference for the @lua-ai-global/device-client Node.js package ## Installation ```bash npm theme={null} npm install @lua-ai-global/device-client ``` ```bash yarn theme={null} yarn add @lua-ai-global/device-client ``` ```bash pnpm theme={null} pnpm add @lua-ai-global/device-client ``` ## DeviceClientConfig The configuration object passed to `new DeviceClient()`. At least one credential field, `deviceCredential` or `apiKey`, is required. For a new installation, first [provision a device credential](/devices/credentials#provision-a-device-credential) for the exact `agentId`, `deviceName`, and operations that the device uses. Agent ID to connect to. Found in `.lua/lua.config.yaml` or the Lua dashboard. Device credential for new provisioning. The credential binding must match `agentId` and `deviceName`. Existing compatibility field. It remains supported indefinitely, including for devices that already use a non-dotted legacy key. Unique name for this device. Lowercase with hyphens (e.g., `label-printer`, `pico-sensor-01`). Array of commands this device supports. Sent to the server at connect time so the agent can use them as tools. See [Self-Describing Commands](/devices/self-describing-commands). Transport protocol. Use `'mqtt'` for constrained devices or environments where MQTT is preferred. Server URL for Socket.IO transport. MQTT broker URL. Required when `transport` is `'mqtt'`. CDN URL for file uploads and downloads. Optional device group name for fan-out commands (e.g., `'printers'`, `'sensors-floor-2'`). Provide either credential field. If you provide both, their values must match. The `deviceCredential` option requires `@lua-ai-global/device-client` 1.1.0 or later. Existing `apiKey` configurations remain supported indefinitely. ## DeviceCommandDefinition Each entry in the `commands` array describes one command the device supports. Command name. Used by the agent to invoke the command (e.g., `read_temperature`). Human-readable description. Shown to the AI agent as the tool description. Write it as if explaining to a person what the command does. JSON Schema for command input parameters. The agent uses this to know what arguments to pass. Command timeout in milliseconds. If the device does not respond within this time, the command fails. Retry configuration for failed commands. The gateway retries with exponential backoff. ## Connection Lifecycle ```typescript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ /* config */ }); // Connect -- resolves when authenticated await device.connect(); // Check connection status console.log(device.isConnected()); // true // Listen for lifecycle events device.on('connected', () => console.log('Connected')); device.on('reconnected', () => console.log('Reconnected')); device.on('disconnected', (reason) => console.log('Disconnected:', reason)); device.on('error', (err) => console.error('Error:', err)); // Graceful shutdown (stops auto-reconnect) await device.disconnect(); ``` The client automatically reconnects on network failures with jittered exponential backoff (1s to 30s). Call `disconnect()` to stop reconnection. The client also registers `SIGTERM` and `SIGINT` handlers for graceful shutdown. ## Handling Commands Register handlers for commands the agent can send to this device: ```typescript theme={null} device.onCommand('read_temperature', async (payload) => { // payload contains whatever the agent sent (validated against inputSchema) const reading = await sensor.read(); return { temperature: reading.celsius, humidity: reading.humidity, timestamp: new Date().toISOString(), }; }); device.onCommand('set_led', async (payload) => { const { color, brightness } = payload; await led.setColor(color); await led.setBrightness(brightness); return { success: true, color, brightness }; }); ``` If a handler throws an error, the error message is returned to the agent as a failed command result. The agent sees the error and can decide how to respond to the user. ## Firing Triggers Send events from the device to the agent: ```typescript theme={null} // Fire a trigger -- resolves when the server acknowledges receipt await device.trigger('temperature_alert', { temperature: 42.1, threshold: 40, sensor: 'main-floor', }); // Optionally listen for trigger execution results device.onTriggerResult('temperature_alert', (result) => { console.log('Agent handled the alert:', result); }); ``` See [Triggers](/devices/triggers) for the full guide on how to handle triggers on the agent side. ## CDN Uploads Every `DeviceClient` instance includes a `cdn` property for uploading and downloading files: ```typescript theme={null} import fs from 'fs'; // Upload a screenshot const screenshot = fs.readFileSync('/tmp/screenshot.png'); const result = await device.cdn.upload(screenshot, 'screenshot.png', 'image/png'); console.log(result.url); // https://cdn.heylua.ai/{fileId} // Get the URL for a previously uploaded file const url = device.cdn.getUrl(result.fileId); // Download a file const buffer = await device.cdn.download(result.fileId); fs.writeFileSync('/tmp/downloaded.png', buffer); ``` See [CDN Uploads](/devices/cdn-uploads) for more details. ## Transport Configuration ```typescript theme={null} const device = new DeviceClient({ agentId: 'your-agent-id', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-device', // transport defaults to 'socketio' // serverUrl defaults to 'https://api.heylua.ai' commands: [ { name: 'ping', description: 'Health check' }, ], }); ``` Socket.IO is best for Node.js applications running on desktops, servers, or single-board computers with plenty of memory. ```typescript theme={null} const device = new DeviceClient({ agentId: 'your-agent-id', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-device', transport: 'mqtt', // mqttUrl defaults to 'wss://mqtt.heylua.ai/mqtt' commands: [ { name: 'ping', description: 'Health check' }, ], }); ``` MQTT is best for constrained devices, battery-powered sensors, or environments with flaky network connectivity. MQTT supports offline message queueing with persistent sessions. ## Complete Example A device that simulates a smart thermostat with temperature reading, target temperature setting, and a high-temperature alert trigger: ```typescript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; let currentTemp = 21.0; let targetTemp = 22.0; let heatingOn = false; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'smart-thermostat', commands: [ { name: 'read_temperature', description: 'Read the current room temperature in celsius', }, { name: 'set_target', description: 'Set the target temperature for the thermostat', inputSchema: { type: 'object', properties: { temperature: { type: 'number', minimum: 10, maximum: 35 }, }, required: ['temperature'], }, }, { name: 'get_status', description: 'Get full thermostat status including heating state', }, ], }); device.onCommand('read_temperature', async () => { return { temperature: currentTemp, unit: 'celsius' }; }); device.onCommand('set_target', async (payload) => { targetTemp = payload.temperature; heatingOn = currentTemp < targetTemp; return { targetTemperature: targetTemp, heatingOn }; }); device.onCommand('get_status', async () => { return { currentTemperature: currentTemp, targetTemperature: targetTemp, heatingOn, timestamp: new Date().toISOString(), }; }); async function main() { device.on('connected', () => console.log('Thermostat online')); device.on('disconnected', (reason) => console.log('Offline:', reason)); await device.connect(); // Simulate temperature changes and fire trigger on high temp setInterval(async () => { currentTemp += (Math.random() - 0.4) * 0.5; currentTemp = Math.round(currentTemp * 10) / 10; if (currentTemp > 35) { await device.trigger('high_temperature', { temperature: currentTemp, threshold: 35, }); } }, 5000); } main().catch(console.error); ``` ## Next Steps Deep dive into MQTT configuration and topic structure Upload and download files from your device Send events from your device to the agent Complete class and method documentation # What are Devices? Source: https://docs.heylua.ai/devices/overview Give your AI agent a remote control for the physical world ## What are Devices? **Devices** let your AI agent reach beyond the cloud and into the physical world. A device is any hardware -- a barcode scanner, a temperature sensor, a kiosk display -- that connects to your agent over the internet and exchanges commands and triggers in real time. A remote control for your agent to reach into the physical world -- send commands to hardware, receive sensor data back, no middleware required **No compile or push needed.** Devices are self-describing. When a device connects, it tells the agent what commands it supports. Those commands instantly become tools the agent can use. Disconnect the device and the tools disappear. ## Why Devices? Agent sends commands to devices. Devices fire triggers back to the agent. Both directions are instant over persistent connections. Devices connect directly to the agent gateway. No cron jobs, no polling APIs, no message queues to maintain. Devices declare their capabilities at connect time. The agent automatically gets tools for every command the device supports. The MicroPython client fits on a Raspberry Pi Pico W with 264KB of RAM. Full MQTT support, command handling, and triggers. ## Before and After ``` Device --> MQTT Broker --> Middleware API --> Cron Job --> Agent | Database (polling) | Dashboard (manual) ``` * Multiple systems to maintain * Polling delays (seconds to minutes) * Custom glue code for every device type * Agent has no direct control ``` Device <--> Agent ``` * One persistent connection * Sub-second latency * Device describes itself -- no glue code * Agent sends commands and receives triggers directly ## Supported Transports | Transport | Best For | Protocol | Library | | ------------- | -------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- | | **Socket.IO** | Node.js devices, desktops, servers | WebSocket over HTTPS | `@lua-ai-global/device-client` (npm) or `lua-device-client` (pip) | | **MQTT** | Microcontrollers, constrained devices, battery-powered sensors | MQTT 3.1.1 over TLS | `@lua-ai-global/device-client` (Node), `lua-device-client` (Python), or `lua_device.py` (MicroPython) | Both transports support the full feature set: commands, triggers, heartbeats, reconnection, and CDN uploads. New Node.js installations and custom protocol clients [provision a device credential](/devices/credentials) for one exact agent, device name, and set of operations. Published Python and MicroPython clients continue to use existing non-dotted legacy keys. These keys remain supported indefinitely. ## What Can You Build? Barcode scanners, weight sensors, gate controllers. Agent manages inventory in real time. Meeting room sensors, thermostats, desk occupancy. Agent acts as a facilities concierge. Vibration sensors, temperature probes, emergency stop buttons. Agent monitors factory health on a Pico W. Receipt printers, NFC readers, display screens. Agent powers customer-facing interactions. Soil moisture sensors, irrigation valves, weather stations. Agent optimizes crop management. Any device that runs Node.js or MicroPython. If it can open a socket, it can talk to your agent. ## How It Works (30 Seconds) Your device opens a Socket.IO or MQTT connection to the Lua gateway and sends a list of commands it supports. Each command becomes a tool the agent can call. The agent sees them just like any other skill tool. A user says "scan the next barcode". The agent picks the right device tool and sends the command. The device executes the command and returns the result. The agent uses the result in its response to the user. The device can also push events to the agent -- "temperature exceeded 40C" -- which run server-side logic. ## Next Steps Provision and manage access for one exact device Connect your first device in under 5 minutes Understand the full command and trigger flow Complete reference for the Node.js device client Run on a Raspberry Pi Pico W # 5-Minute Quickstart Source: https://docs.heylua.ai/devices/quickstart Connect your first device to a Lua agent in under 5 minutes ## Connect Your First Device Get a device connected and responding to agent commands. ```bash npm theme={null} npm install @lua-ai-global/device-client ``` ```bash yarn theme={null} yarn add @lua-ai-global/device-client ``` ```bash pnpm theme={null} pnpm add @lua-ai-global/device-client ``` ```bash pip theme={null} pip install lua-device-client ``` For Node.js, create a credential bound to your exact agent, the device name `my-first-device`, and the `commands` operation. Follow [Provision a device credential](/devices/credentials#provision-a-device-credential), then save the returned secret in an environment variable: ```bash theme={null} export LUA_DEVICE_CREDENTIAL="your-device-credential" ``` The published Python 1.3.0 client uses `api_key`. Set `LUA_API_KEY` to an existing non-dotted legacy key. Existing legacy configurations remain supported indefinitely. For the Python example, set the variable that its `api_key` field reads: ```bash theme={null} export LUA_API_KEY="your-existing-legacy-key" ``` Create a file called `device.ts` or `device.py`. Use the same agent ID and device name that you used at provisioning: ```typescript TypeScript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: 'your-agent-id', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-first-device', commands: [ { name: 'ping', description: 'Check if the device is alive', }, { name: 'read_sensor', description: 'Read the current temperature in celsius', inputSchema: { type: 'object', properties: { unit: { type: 'string', enum: ['celsius', 'fahrenheit'], default: 'celsius' }, }, }, }, ], }); ``` ```python Python theme={null} import os from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id="your-agent-id", api_key=os.environ["LUA_API_KEY"], device_name="my-first-device", commands=[ DeviceCommandDefinition( name="ping", description="Check if the device is alive", ), DeviceCommandDefinition( name="read_sensor", description="Read the current temperature in celsius", input_schema={ "type": "object", "properties": { "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}, }, }, ), ], ) ``` Find your `agentId` in `.lua/lua.config.yaml` after running `lua init`, or in the [Lua dashboard](https://admin.heylua.ai). Tell the device what to do when the agent sends each command: ```typescript TypeScript theme={null} device.onCommand('ping', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; }); device.onCommand('read_sensor', async (payload) => { // In a real device, read from hardware here const tempCelsius = 22.5; const temp = payload?.unit === 'fahrenheit' ? (tempCelsius * 9/5) + 32 : tempCelsius; return { temperature: temp, unit: payload?.unit || 'celsius' }; }); ``` ```python Python theme={null} from datetime import datetime, timezone @client.on_command("ping") async def handle_ping(payload): return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("read_sensor") async def handle_read_sensor(payload): # In a real device, read from hardware here temp_celsius = 22.5 unit = (payload or {}).get("unit", "celsius") temp = (temp_celsius * 9 / 5) + 32 if unit == "fahrenheit" else temp_celsius return {"temperature": temp, "unit": unit} ``` ```typescript TypeScript theme={null} async function main() { await device.connect(); console.log('Device connected and ready for commands'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio async def main(): await client.connect() print("Device connected and ready for commands") asyncio.run(main()) ``` Run your device: ```bash TypeScript theme={null} npx tsx device.ts ``` ```bash Python theme={null} python device.py ``` You should see: ``` Device connected and ready for commands ``` In another terminal, start a chat session with your agent: ```bash theme={null} lua chat ``` Try saying: ``` > Ping my-first-device > What's the temperature reading from my-first-device? > Read the sensor in fahrenheit ``` The agent will use the device tools to send commands and return the results. **No compile or push needed.** Your device declared its commands at connect time. The agent already has tools for them. Change the commands array, restart the device, and the agent sees the new tools instantly. ## Complete Working Example Here is the full file in one piece: ```typescript TypeScript theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: 'your-agent-id', deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'my-first-device', commands: [ { name: 'ping', description: 'Check if the device is alive', }, { name: 'read_sensor', description: 'Read the current temperature in celsius', inputSchema: { type: 'object', properties: { unit: { type: 'string', enum: ['celsius', 'fahrenheit'], default: 'celsius' }, }, }, }, ], }); device.onCommand('ping', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; }); device.onCommand('read_sensor', async (payload) => { const tempCelsius = 22.5; const temp = payload?.unit === 'fahrenheit' ? (tempCelsius * 9/5) + 32 : tempCelsius; return { temperature: temp, unit: payload?.unit || 'celsius' }; }); async function main() { await device.connect(); console.log('Device connected and ready for commands'); } main().catch(console.error); ``` ```python Python theme={null} import asyncio import os from datetime import datetime, timezone from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id="your-agent-id", api_key=os.environ["LUA_API_KEY"], device_name="my-first-device", commands=[ DeviceCommandDefinition( name="ping", description="Check if the device is alive", ), DeviceCommandDefinition( name="read_sensor", description="Read the current temperature in celsius", input_schema={ "type": "object", "properties": { "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}, }, }, ), ], ) @client.on_command("ping") async def handle_ping(payload): return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} @client.on_command("read_sensor") async def handle_read_sensor(payload): temp_celsius = 22.5 unit = (payload or {}).get("unit", "celsius") temp = (temp_celsius * 9 / 5) + 32 if unit == "fahrenheit" else temp_celsius return {"temperature": temp, "unit": unit} async def main(): await client.connect() print("Device connected and ready for commands") asyncio.run(main()) ``` ## Next Steps Understand how commands and triggers flow through the system Learn how devices declare their capabilities Let your device push events to the agent Full API reference for the device client # Self-Describing Commands Source: https://docs.heylua.ai/devices/self-describing-commands How devices declare their capabilities without any server-side configuration ## The Key Innovation Traditional IoT platforms require you to define device capabilities on the server, then write matching firmware, then keep both in sync. With Lua devices, the device itself declares what it can do. There is no server-side schema to maintain. Each device carries its own resume. When it connects, the agent reads the resume and knows exactly what the device can do. When a device connects, it sends an array of `DeviceCommandDefinition` objects. The gateway stores these alongside the connection. The agent runtime reads them and creates tools automatically. When the device disconnects, the tools disappear. ## DeviceCommandDefinition Fields Each command definition has the following fields: ```typescript theme={null} interface DeviceCommandDefinition { /** Command name (used by agent to invoke) */ name: string; /** Human-readable description (shown to the AI agent as tool description) */ description: string; /** JSON Schema for command input parameters */ inputSchema?: Record; /** Command timeout in milliseconds (default: 30000) */ timeoutMs?: number; /** Retry configuration for failed commands */ retry?: { maxAttempts: number; backoffMs: number }; } ``` The command name. This becomes part of the tool name the agent sees (`device:{deviceName}:{name}`). Use lowercase with underscores (e.g., `read_temperature`, `set_brightness`). A natural-language description of what the command does. The AI agent reads this to decide when to use the tool. Write it as if explaining to a person: "Read the current room temperature in celsius" is better than "temp read". A JSON Schema object describing the parameters the command accepts. The agent uses this to construct the correct payload. How long to wait for the device to respond before the command fails. Increase for slow operations like printing or scanning. Automatic retry on failure. The gateway retries with exponential backoff starting at `backoffMs`. ## JSON Schema Examples ### No Parameters ```typescript TypeScript theme={null} { name: 'get_status', description: 'Get the current status of the printer including paper level and ink', } ``` ```python Python theme={null} DeviceCommandDefinition( name="get_status", description="Get the current status of the printer including paper level and ink", ) ``` ### Simple Parameters ```typescript TypeScript theme={null} { name: 'set_brightness', description: 'Set the display brightness level', inputSchema: { type: 'object', properties: { level: { type: 'number', minimum: 0, maximum: 100, description: 'Brightness percentage (0-100)', }, }, required: ['level'], }, } ``` ```python Python theme={null} DeviceCommandDefinition( name="set_brightness", description="Set the display brightness level", input_schema={ "type": "object", "properties": { "level": { "type": "number", "minimum": 0, "maximum": 100, "description": "Brightness percentage (0-100)", }, }, "required": ["level"], }, ) ``` ### Enum Parameters ```typescript TypeScript theme={null} { name: 'set_mode', description: 'Switch the device operating mode', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['idle', 'active', 'maintenance', 'sleep'], description: 'Target operating mode', }, }, required: ['mode'], }, } ``` ```python Python theme={null} DeviceCommandDefinition( name="set_mode", description="Switch the device operating mode", input_schema={ "type": "object", "properties": { "mode": { "type": "string", "enum": ["idle", "active", "maintenance", "sleep"], "description": "Target operating mode", }, }, "required": ["mode"], }, ) ``` ### Complex Parameters ```typescript TypeScript theme={null} { name: 'print_label', description: 'Print a shipping label with the given details', inputSchema: { type: 'object', properties: { recipient: { type: 'object', properties: { name: { type: 'string' }, address: { type: 'string' }, city: { type: 'string' }, postalCode: { type: 'string' }, }, required: ['name', 'address', 'city', 'postalCode'], }, copies: { type: 'number', minimum: 1, maximum: 10, default: 1, }, }, required: ['recipient'], }, timeoutMs: 60000, retry: { maxAttempts: 2, backoffMs: 1000 }, } ``` ```python Python theme={null} DeviceCommandDefinition( name="print_label", description="Print a shipping label with the given details", input_schema={ "type": "object", "properties": { "recipient": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, "city": {"type": "string"}, "postalCode": {"type": "string"}, }, "required": ["name", "address", "city", "postalCode"], }, "copies": { "type": "number", "minimum": 1, "maximum": 10, "default": 1, }, }, "required": ["recipient"], }, timeout_ms=60000, retry={"maxAttempts": 2, "backoffMs": 1000}, ) ``` ## Validation Rules * **`name`** must be unique within a single device. Two devices can have commands with the same name. * **`description`** should be a complete sentence. The agent treats it as a tool description. * **`inputSchema`** must be valid JSON Schema draft-07. The `type` at the top level should be `'object'`. * **`timeoutMs`** minimum is 1000 (1 second). The default of 30000 (30 seconds) works for most commands. ## Dynamic Updates Commands are sent at connect time. To change the command list, update the `commands` array in your `DeviceClientConfig` and restart the device (or disconnect and reconnect). ```typescript TypeScript theme={null} // Version 1: basic sensor const device = new DeviceClient({ deviceName: 'env-sensor', commands: [ { name: 'read_temperature', description: 'Read temperature' }, ], // ... }); // Version 2: added humidity and calibration const device = new DeviceClient({ deviceName: 'env-sensor', commands: [ { name: 'read_temperature', description: 'Read temperature in celsius' }, { name: 'read_humidity', description: 'Read relative humidity percentage' }, { name: 'calibrate', description: 'Run sensor calibration routine', timeoutMs: 60000 }, ], // ... }); ``` ```python Python theme={null} # Version 1: basic sensor client = DeviceClient( device_name="env-sensor", commands=[ DeviceCommandDefinition(name="read_temperature", description="Read temperature"), ], # ... ) # Version 2: added humidity and calibration client = DeviceClient( device_name="env-sensor", commands=[ DeviceCommandDefinition(name="read_temperature", description="Read temperature in celsius"), DeviceCommandDefinition(name="read_humidity", description="Read relative humidity percentage"), DeviceCommandDefinition(name="calibrate", description="Run sensor calibration routine", timeout_ms=60000), ], # ... ) ``` No `lua push` or `lua deploy` needed. Just restart the device. **When a device goes offline, its tools disappear from the agent.** If a user asks the agent to use a device tool and the device is not connected, the agent will not have that tool available. Design your agent persona to handle this gracefully (e.g., "The sensor is currently offline"). ## Next Steps How device commands become tools the agent can use Complete DeviceClientConfig reference The other direction -- device events sent to the agent See self-describing commands in action # Triggers Source: https://docs.heylua.ai/devices/triggers Send events from devices to your agent and handle them with server-side logic ## Two Sides of a Trigger A device trigger has two sides: 1. **Device side** -- the device fires the trigger using `client.trigger()` or `device.trigger()` 2. **Agent side** -- you define what happens when the trigger arrives using `defineDeviceTrigger()` The device sends the event. The agent handles it. A doorbell. The device presses it (fires the trigger). Your agent-side code decides what to do when it rings (the execute function). ## Device Side: Firing Triggers ### Node.js ```typescript theme={null} // Fire a trigger with a payload await device.trigger('low_stock', { sku: 'WIDGET-001', currentQuantity: 3, threshold: 10, location: 'warehouse-A', }); ``` The `trigger()` method resolves when the server acknowledges receipt. It does not wait for the agent to finish processing. ### Python ```python theme={null} # Fire a trigger with a payload await client.trigger("low_stock", { "sku": "WIDGET-001", "currentQuantity": 3, "threshold": 10, "location": "warehouse-A", }) ``` ### MicroPython ```python theme={null} device.trigger("low_stock", { "sku": "WIDGET-001", "currentQuantity": 3, "threshold": 10, "location": "warehouse-A", }) ``` ## Agent Side: Handling Triggers On the agent side, create a device trigger primitive using `defineDeviceTrigger()`. This is a standalone file that gets compiled, pushed, and deployed like any other Lua primitive. ```typescript theme={null} // src/triggers/low-stock.ts import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const lowStock = defineDeviceTrigger({ name: 'low-stock', description: 'Fired when inventory for an item drops below its reorder threshold', payloadSchema: z.object({ sku: z.string(), currentQuantity: z.number(), threshold: z.number(), location: z.string(), }), execute: async (payload, { agent, device }) => { await agent.chat( `Device ${device.name} reports low stock for SKU ${payload.sku}. ` + `Current quantity: ${payload.currentQuantity}, threshold: ${payload.threshold}. ` + `Location: ${payload.location}. Please create a reorder request.` ); }, }); ``` The trigger `name` on the agent side uses hyphens (e.g., `low-stock`). The trigger name on the device side uses underscores (e.g., `low_stock`). The gateway maps between the two conventions automatically. ## Both Sides Together ```typescript Device Side (Node.js) theme={null} import { DeviceClient } from '@lua-ai-global/device-client'; const device = new DeviceClient({ agentId: process.env.LUA_AGENT_ID!, deviceCredential: process.env.LUA_DEVICE_CREDENTIAL!, deviceName: 'temp-monitor', commands: [ { name: 'read_temperature', description: 'Read current temperature' }, ], }); device.onCommand('read_temperature', async () => { const temp = readSensor(); return { temperature: temp, unit: 'celsius' }; }); async function main() { await device.connect(); // Check temperature every 30 seconds, trigger alert if too high setInterval(async () => { const temp = readSensor(); if (temp > 40) { await device.trigger('temperature_alert', { temperature: temp, threshold: 40, sensor: 'main-hall', }); } }, 30000); } function readSensor(): number { return 20 + Math.random() * 25; // simulated } main().catch(console.error); ``` ```python Device Side (Python) theme={null} import asyncio import random from lua_device import DeviceClient, DeviceCommandDefinition client = DeviceClient( agent_id="your-agent-id", api_key="your-api-key", device_name="temp-monitor", commands=[ DeviceCommandDefinition(name="read_temperature", description="Read current temperature"), ], ) def read_sensor() -> float: return 20 + random.random() * 25 # simulated @client.on_command("read_temperature") async def handle_read_temperature(payload): temp = read_sensor() return {"temperature": temp, "unit": "celsius"} async def main(): await client.connect() # Check temperature every 30 seconds, trigger alert if too high while True: temp = read_sensor() if temp > 40: await client.trigger("temperature_alert", { "temperature": temp, "threshold": 40, "sensor": "main-hall", }) await asyncio.sleep(30) asyncio.run(main()) ``` ```typescript Agent Side (src/triggers/temperature-alert.ts) theme={null} import { defineDeviceTrigger } from 'lua-cli'; import { z } from 'zod'; export const temperatureAlert = defineDeviceTrigger({ name: 'temperature-alert', description: 'Fired when a temperature sensor exceeds its threshold', payloadSchema: z.object({ temperature: z.number(), threshold: z.number(), sensor: z.string(), }), execute: async (payload, { agent, device }) => { const message = `ALERT: Device "${device.name}" sensor "${payload.sensor}" ` + `reads ${payload.temperature}C (threshold: ${payload.threshold}C). ` + `Please investigate and take appropriate action.`; await agent.chat(message); }, }); ``` ## Deploying Device Triggers Device triggers are compiled and pushed like other Lua primitives: ```bash theme={null} lua push device-trigger ``` Or push everything at once: ```bash theme={null} lua push ``` Then deploy: ```bash theme={null} lua deploy ``` ## LuaDeviceTriggerConfig Reference Trigger name. Lowercase with hyphens (e.g., `paper-low`, `temperature-alert`). Description of when this trigger fires. Helps with debugging and documentation. Zod schema for validating the trigger payload. The payload is validated before `execute` runs. The function that runs when the trigger fires. Receives the validated payload and a context object. ### Execute Context The `execute` function receives a context object: | Property | Type | Description | | -------- | ------------------ | -------------------------------------------------------------------- | | `agent` | object | Agent context. Call `agent.chat()` to send messages or invoke tools. | | `device` | `{ name: string }` | Information about the device that fired the trigger. | ## Trigger Result Listening (Optional) On the device side, you can optionally listen for the result of trigger execution: ```typescript TypeScript theme={null} device.onTriggerResult('temperature_alert', (result) => { console.log('Agent handled the alert:', result); // e.g., result might contain actions taken }); ``` ```python Python theme={null} @client.on_trigger_result("temperature_alert") async def handle_alert_result(result): print("Agent handled the alert:", result) # e.g., result might contain actions taken ``` This is optional. Most triggers are fire-and-forget. ## Next Steps How the other direction works -- agent sending commands to devices How devices declare their command capabilities Full example with triggers for low stock alerts MicroPython trigger example for vibration anomalies # Baskets Tools Example Source: https://docs.heylua.ai/examples/baskets Complete shopping cart workflow with 9 tools ## Overview **File**: `src/tools/BasketTool.ts` Nine tools demonstrating the complete shopping cart workflow from creation to checkout. ## The Complete Shopping Flow ```mermaid theme={null} graph TD A[Create Basket] --> B[Add Items] B --> C[Update Metadata] C --> D[Checkout] D --> E[Order Created!] ``` Start a new shopping session Add products to cart (can add multiple) Remove items, update quantities, add notes Convert basket to order ## Key Tools ### CreateBasketTool ```typescript theme={null} import { LuaTool, Baskets } from 'lua-cli'; import { z } from 'zod'; export class CreateBasketTool implements LuaTool { name = "create_basket"; description = "Create a new shopping basket"; inputSchema = z.object({ currency: z.string().default('USD') }); async execute(input: z.infer) { const basket = await Baskets.create({ currency: input.currency, metadata: { createdBy: 'chat' } }); return { basketId: basket.id, message: "New basket created! Start adding items." }; } } ``` ### AddItemToBasketTool ```typescript theme={null} export class AddItemToBasketTool implements LuaTool { name = "add_to_basket"; description = "Add a product to the shopping basket"; inputSchema = z.object({ basketId: z.string(), productId: z.string(), quantity: z.number().min(1).default(1) }); async execute(input: z.infer) { // Get product to get current price const product = await Products.getById(input.productId); // Check stock if (!product.inStock) { return { success: false, message: `${product.name} is currently out of stock` }; } // Add to basket const updated = await Baskets.addItem(input.basketId, { id: input.productId, price: product.price, quantity: input.quantity, SKU: product.sku }); return { basketId: updated.id, itemCount: updated.common.itemCount, total: `$${updated.common.totalAmount.toFixed(2)}`, message: `Added ${input.quantity}x ${product.name} to basket` }; } } ``` ### CheckoutBasketTool ```typescript theme={null} export class CheckoutBasketTool implements LuaTool { name = "checkout_basket"; description = "Complete purchase and create order"; inputSchema = z.object({ basketId: z.string(), shippingAddress: z.object({ street: z.string(), city: z.string(), zip: z.string() }), paymentMethod: z.string().default('stripe') }); async execute(input: z.infer) { const order = await Baskets.placeOrder({ shippingAddress: input.shippingAddress, paymentMethod: input.paymentMethod }, input.basketId); return { orderId: order.id, status: order.common.status, total: `$${order.common.totalAmount.toFixed(2)}`, message: "Order created successfully!" }; } } ``` ## All 9 Tools 1. **CreateBasketTool** - Start shopping 2. **GetBasketsTool** - List all baskets 3. **AddItemToBasketTool** - Add products 4. **RemoveItemFromBasketTool** - Remove products 5. **ClearBasketTool** - Empty cart 6. **UpdateBasketStatusTool** - Change status 7. **UpdateBasketMetadataTool** - Add notes/data 8. **CheckoutBasketTool** - Convert to order 9. **GetBasketByIdTool** - View specific basket ## Testing the Flow ```bash theme={null} lua chat ``` Select sandbox mode, then try this conversation: 1. "Create a shopping basket" 2. "Search for laptop" 3. "Add that laptop to my basket" 4. "Show me my cart" 5. "Checkout with shipping to 123 Main St, New York" ## What You'll Learn Handle complex processes Track cart state over time Combine multiple API calls Implement e-commerce rules ## Next Steps Complete API reference Manage orders after checkout # Custom Data Tools Example Source: https://docs.heylua.ai/examples/custom-data Vector search and custom data collections ## Overview **File**: `src/tools/CustomDataTool.ts` Demonstrates the powerful Custom Data API with semantic vector search. The example uses a movie database, but the patterns work for any searchable content. ## What Makes This Special **Vector Search** = Semantic Understanding Traditional search: * Query: "Inception" → Finds "Inception" ✅ * Query: "dream movie" → Finds nothing ❌ Vector search: * Query: "Inception" → Finds "Inception" ✅ * Query: "dream movie" → Finds "Inception"! ✅ * Query: "mind-bending thriller" → Finds similar movies! ✅ ## Complete Tools ### Create Movie Tool ```typescript theme={null} import { LuaTool, Data } from 'lua-cli'; import { z } from 'zod'; export class CreateMovieTool implements LuaTool { name = "create_movie"; description = "Add a new movie to the database"; inputSchema = z.object({ title: z.string(), director: z.string(), year: z.number(), genre: z.string(), description: z.string().optional() }); async execute(input: z.infer) { // ⭐ KEY: Create searchable text with all relevant info const searchText = [ input.title, input.director, input.genre, input.description ].filter(Boolean).join(' '); const movie = await Data.create('movies', input, searchText); return { id: movie.id, message: `Added "${input.title}" to database` }; } } ``` ### Search Movies Tool ```typescript theme={null} export class SearchMoviesTool implements LuaTool { name = "search_movies"; description = "Search movies by title, director, genre, or theme"; inputSchema = z.object({ query: z.string().describe("Search query (can be descriptive)") }); async execute(input: z.infer) { // ⭐ Vector search with similarity threshold const results = await Data.search( 'movies', input.query, 10, // Max 10 results 0.7 // Min similarity score ); return { movies: results.map(entry => ({ id: entry.id, title: entry.title, year: entry.year, director: entry.director, relevance: Math.round(entry.score * 100) + '%' })), count: results.length }; } } ``` ### Get Movie Tool ```typescript theme={null} export class GetMovieByIdTool implements LuaTool { name = "get_movie"; description = "Get detailed information about a specific movie"; inputSchema = z.object({ id: z.string() }); async execute(input: z.infer) { const movie = await Data.getEntry('movies', input.id); return movie.data; } } ``` ## Key Concepts ### 1. Search Text is Critical The `searchText` parameter determines what the AI can find: ```typescript theme={null} // ✅ Good - Rich search text const searchText = `${input.title} ${input.director} ${input.genre} ${input.description} ${input.tags.join(' ')}`; // ❌ Bad - Only title const searchText = input.title; ``` ### 2. Similarity Scores Understanding score thresholds: * `1.0` = Perfect match * `0.8-0.9` = Very similar * `0.7-0.8` = Somewhat similar * `0.6-0.7` = Loosely related * `<0.6` = May be irrelevant ```typescript theme={null} // Strict (high precision) await Data.search('movies', query, 10, 0.8); // Balanced (recommended) await Data.search('movies', query, 10, 0.7); // Loose (high recall) await Data.search('movies', query, 10, 0.6); ``` ### 3. Natural Language Queries Users can search naturally: ```typescript theme={null} // All of these work: "sci-fi movies about space" "Christopher Nolan films" "thriller with plot twists" "movies like Inception" "romantic comedies from 2020" ``` ### 4. Index Your Filter Fields Vector search scales automatically — **filtered queries do not**. If your tools call `Data.get(collection, { field: value })` and the collection grows (imports, CDC syncs, event logs), declare the filtered fields when storing: ```typescript theme={null} await Data.create('movies', movie, { searchText: buildSearchText(movie), index: ['year'], // you filter by year // index: [['genre', 'year']], // compound: filtered together }); ``` The platform builds an agent-scoped index within minutes and retires it automatically \~14 days after your agent stops using it — no cleanup code. Skip this on a large collection and filtered queries eventually fail with an error telling you exactly which field to declare. Check build status any time with `Data.collections()`. ## Testing ```bash theme={null} lua test ``` Try semantic searches: * "mind-bending thriller" → Should find Inception * "Christopher Nolan movies" → Should find his films * "space exploration" → Should find relevant sci-fi * "romantic comedy" → Should find rom-coms ## Use Cases ### Knowledge Base ```typescript theme={null} await Data.create('articles', { title: 'How to Reset Password', content: 'Step by step guide...', category: 'Account' }, 'password reset account help guide'); // Users can find with: // - "forgot password" // - "can't log in" // - "reset account" ``` ### Product Recommendations ```typescript theme={null} await Data.create('products', { name: 'Wireless Headphones', description: 'Noise cancelling...' }, 'wireless headphones bluetooth noise cancelling audio'); // Users can find with: // - "best headphones for travel" // - "noise canceling earphones" // - "bluetooth audio" ``` ### Customer Notes ```typescript theme={null} await Data.create('customers', { name: 'John Doe', company: 'Acme Corp', notes: 'Interested in enterprise plan' }, 'John Doe Acme Corp enterprise interested sales'); // Find with: // - "enterprise customers" // - "Acme contacts" // - "sales leads" ``` ## Customization Ideas ### Add Ratings ```typescript theme={null} inputSchema = z.object({ ...existing, rating: z.number().min(0).max(10) }); // Sort by rating results.sort((a, b) => b.rating - a.rating); ``` ### Add Filters ```typescript theme={null} // Search with filters const results = await Data.search('movies', query, 50, 0.7); // Filter by year const recentMovies = results.filter(m => m.year >= 2020 ); ``` ### Update Movies ```typescript theme={null} export class UpdateMovieTool implements LuaTool { name = "update_movie"; description = "Update movie information"; inputSchema = z.object({ id: z.string(), data: z.object({ rating: z.number().optional(), awards: z.array(z.string()).optional(), description: z.string().optional() }), updateSearchText: z.boolean().optional() }); async execute(input: z.infer) { const movie = await Data.getEntry('movies', input.id); // Optionally update search text if description changed const searchText = input.updateSearchText ? `${movie.title} ${movie.director} ${input.data.description || ''}` : undefined; await Data.update('movies', input.id, input.data, searchText); return { success: true, message: `Updated movie ${movie.title}` }; } } ``` ### Using save() Method ```typescript theme={null} export class UpdateMovieRatingTool implements LuaTool { name = "update_movie_rating"; description = "Update movie rating and review"; inputSchema = z.object({ id: z.string(), rating: z.number().min(0).max(10), review: z.string().optional() }); async execute(input: z.infer) { // Get the entry const movie = await Data.getEntry('movies', input.id); // Modify properties directly movie.rating = input.rating; if (input.review) { movie.review = input.review; } movie.updatedAt = new Date().toISOString(); // Save all changes at once // Optionally update search text if review added const searchText = input.review ? `${movie.title} ${movie.director} rating ${input.rating} ${input.review}` : undefined; await movie.save(searchText); return { success: true, message: `Updated "${movie.title}" rating to ${input.rating}` }; } } ``` ## What You'll Learn Semantic similarity search with AI Store any data structure Optimize for findability Tune precision vs recall ## Next Steps Complete Data API documentation Uses Data API with vector search # Tool Examples Overview Source: https://docs.heylua.ai/examples/overview Learn from 30+ working tool examples ## Available Examples The template project includes 30+ working tools demonstrating all major patterns and use cases. External API integration User profile management (2 tools) E-commerce catalog (6 tools) Shopping cart workflow (9 tools) Vector search database (6 tools) Stripe integration ## Learning Path **Weather Tool** - External API integration Shows how to call external APIs and handle responses **User Data Tools** - Simple platform API usage Learn how to use Lua's built-in APIs **Products Tools** - Complete CRUD pattern Create, read, update, delete with pagination **Custom Data Tools** - Semantic search Most powerful feature! Build searchable knowledge bases **Baskets Tools** - Multi-step processes Handle complex business logic and state ## Tool Categories ### External APIs (1 tool) Tools that integrate with external services: **File**: `GetWeatherTool.ts` **What it does**: Fetches real-time weather using Open-Meteo API **Learn**: * Making HTTP requests * Handling API responses * Error handling * No API key required ### Platform APIs (27 tools) Tools using Lua's built-in platform APIs: * `get_user_data` - Retrieve user info * `update_user_data` - Update profile **Learn**: Basic platform API usage * `search_products` - Search catalog * `get_all_products` - List with pagination * `create_product` - Add new items * `update_product` - Modify existing * `get_product_by_id` - Get specific * `delete_product` - Remove items **Learn**: Complete CRUD operations * `create_basket` - Start shopping * `get_baskets` - List carts * `add_to_basket` - Add items * `remove_from_basket` - Remove items * `clear_basket` - Empty cart * `update_basket_status` - Change status * `update_basket_metadata` - Add notes * `checkout_basket` - Convert to order * `get_basket_by_id` - View specific **Learn**: Multi-step workflows * `create_order` - From basket * `update_order_status` - Track fulfillment * `get_order_by_id` - Order details * `get_user_orders` - List user orders **Learn**: Order management * `create_movie` - Add with indexing * `get_movies` - List all * `get_movie_by_id` - Get specific * `update_movie` - Modify * `search_movies` - **Semantic search** * `delete_movie` - Remove **Learn**: **Vector search** (most powerful!) ### Integrations (2 tools) Stripe payment link creation **Learn**: Environment variables, external services Simple example **Learn**: Basic tool structure ## Quick Reference | Tool | Complexity | Best For Learning | | --------------- | ------------ | ----------------- | | CreatePostTool | ⭐ Easy | Tool basics | | GetWeatherTool | ⭐⭐ Medium | External APIs | | UserDataTools | ⭐ Easy | Platform APIs | | ProductsTools | ⭐⭐ Medium | CRUD operations | | CustomDataTools | ⭐⭐⭐ Advanced | Vector search | | BasketsTools | ⭐⭐⭐ Complex | Workflows | | OrderTools | ⭐⭐ Medium | Order management | | PaymentTool | ⭐⭐⭐ Advanced | Integrations | ## How to Use These Examples ### Approach 1: Copy and Modify 1. Find similar tool (e.g., `CustomDataTool.ts` for searchable data) 2. Copy the file 3. Rename (e.g., `ArticleTool.ts`) 4. Update names, descriptions 5. Modify logic for your domain **Time**: 15-30 minutes per tool ### Approach 2: Learn Pattern, Build New 1. Read an example tool 2. Understand the pattern 3. Close the file 4. Build your own from memory 5. Refer back if stuck **Time**: 30-60 minutes per tool **Learning**: Deeper understanding ### Approach 3: Mix and Match 1. Keep useful examples as-is 2. Delete irrelevant ones 3. Add your custom tools alongside 4. Deploy mix of examples + custom **Time**: Variable **Best for**: Quick prototyping ## Testing Examples ```bash theme={null} # Test conversationally (primary method) lua chat # Try in sandbox mode: # - "What's the weather in Tokyo?" # - "Show me your products" # - "Find movies about space" # Test individual tools (optional) lua test # Select a tool: # - get_weather: Enter "London" # - search_products: Enter "laptop" # - search_movies: Enter "thriller" ``` ## Common Patterns Demonstrated **Example**: `GetWeatherTool.ts` ```typescript theme={null} const response = await fetch(apiUrl); const data = await response.json(); return transformedData; ``` **Example**: `ProductsTool.ts` ```typescript theme={null} import { Products } from 'lua-cli'; const products = await Products.search(query); ``` **Example**: `CustomDataTool.ts` ```typescript theme={null} // Create with search text await Data.create('items', data, searchableText); // Search semantically const results = await Data.search('items', query, 10, 0.7); ``` **Example**: `BasketTool.ts` ```typescript theme={null} // Step 1: Create const basket = await Baskets.create({...}); // Step 2: Add items await Baskets.addItem(basket.id, {...}); // Step 3: Checkout const order = await Baskets.placeOrder({...}, basket.id); ``` **Example**: `PaymentTool.ts` ```typescript theme={null} import { env } from 'lua-cli'; const apiKey = env('STRIPE_API_KEY'); ``` ## Next Steps Start with external API integration Learn powerful vector search Follow step-by-step tutorial Understand the project structure # Payment Tool Example Source: https://docs.heylua.ai/examples/payment Stripe payment integration with environment variables ## Overview **File**: `src/tools/PaymentTool.ts` Demonstrates payment integration using Stripe API with secure environment variable management. ## Complete Code ```typescript theme={null} import { LuaTool, env } from 'lua-cli'; import { z } from 'zod'; export default class CreatePaymentLinkTool implements LuaTool { name = "create_payment_link"; description = "Create a payment checkout link via Stripe"; inputSchema = z.object({ amount: z.number().positive().describe("Amount in dollars"), currency: z.string().default('USD'), description: z.string() }); async execute(input: z.infer) { // ⭐ Get API key from environment const stripeKey = env('STRIPE_API_KEY'); // ⭐ Validate it exists if (!stripeKey) { throw new Error( 'STRIPE_API_KEY not configured. ' + 'Please add it to your .env file or use `lua env` for production' ); } // Create Stripe checkout session const response = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { 'Authorization': `Bearer ${stripeKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ 'line_items[0][price_data][currency]': input.currency.toLowerCase(), 'line_items[0][price_data][unit_amount]': (input.amount * 100).toString(), 'line_items[0][price_data][product_data][name]': input.description, 'line_items[0][quantity]': '1', 'mode': 'payment', 'success_url': 'https://example.com/success', 'cancel_url': 'https://example.com/cancel' }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Stripe API error: ${error}`); } const session = await response.json(); return { paymentUrl: session.url, sessionId: session.id, amount: `$${input.amount.toFixed(2)}`, message: "Payment link created successfully" }; } } ``` ## Key Concepts ### 1. Environment Variables **Never hardcode API keys!** ```typescript theme={null} // ❌ Bad const apiKey = 'sk_test_abc123'; // ✅ Good import { env } from 'lua-cli'; const apiKey = env('STRIPE_API_KEY'); ``` ### 2. Validation Always check environment variables exist: ```typescript theme={null} const apiKey = env('STRIPE_API_KEY'); if (!apiKey) { throw new Error( 'STRIPE_API_KEY not configured. ' + 'Add it to .env file or use `lua env` for production' ); } ``` ### 3. Error Handling Handle external API failures gracefully: ```typescript theme={null} if (!response.ok) { const error = await response.text(); throw new Error(`Stripe API error: ${error}`); } ``` ### 4. Amount Conversion Stripe uses cents, not dollars: ```typescript theme={null} // Convert dollars to cents const amountInCents = input.amount * 100; 'unit_amount': amountInCents.toString() ``` ## Setup Required ### 1. Get Stripe API Key 1. Create account at [https://stripe.com](https://stripe.com) 2. Go to Dashboard → Developers → API Keys 3. Copy "Secret key" (starts with `sk_test_`) ### 2. Add to .env File Create `.env` in project root: ```bash theme={null} STRIPE_API_KEY=sk_test_your_key_here ``` ### 3. Test ```bash theme={null} lua test ``` Select `create_payment_link`: * Amount: `29.99` * Currency: `USD` * Description: `Product Purchase` ## Customization Ideas ### Add Customer Info ```typescript theme={null} inputSchema = z.object({ amount: z.number(), customerEmail: z.string().email() }); // In Stripe API call body: { customer_email: input.customerEmail, // ... other fields } ``` ### Add Success/Cancel URLs ```typescript theme={null} inputSchema = z.object({ amount: z.number(), successUrl: z.string().url(), cancelUrl: z.string().url() }); // Use in API call 'success_url': input.successUrl, 'cancel_url': input.cancelUrl ``` ### Add Metadata ```typescript theme={null} // Track custom data const orderId = generateOrderId(); body: { metadata: { orderId, source: 'ai_chat', timestamp: Date.now() } } ``` ## Other Payment Providers Same pattern works for other providers: ```typescript theme={null} const apiKey = env('PAYPAL_CLIENT_ID'); // PayPal API integration ``` ```typescript theme={null} const apiKey = env('SQUARE_ACCESS_TOKEN'); // Square API integration ``` ```typescript theme={null} const apiKey = env('PAYMENT_API_KEY'); const apiUrl = env('PAYMENT_API_URL'); // Custom payment integration ``` ## Security Best Practices ```bash theme={null} # .env (development) STRIPE_API_KEY=sk_test_abc123 ``` ```yaml theme={null} # lua.skill.yaml (production) skill: env: STRIPE_API_KEY: sk_live_xyz789 ``` ```typescript theme={null} // ❌ Bad console.log('API Key:', apiKey); // ✅ Good console.log('API Key configured:', !!apiKey); ``` ```typescript theme={null} if (input.amount < 0.50) { throw new Error('Minimum amount is $0.50'); } if (input.amount > 999999) { throw new Error('Amount exceeds maximum limit'); } ``` ``` # .gitignore .env .env.local .env.*.local ``` ## What You'll Learn Secure secret management Third-party API integration Real payment workflows Graceful failure handling ## Next Steps Complete guide to configuration API reference for env() # Proactive Inbox Source: https://docs.heylua.ai/examples/proactive-inbox A monitoring skill that pushes notices, approvals, and connection fixes to the user's desk ## What we're building A refund-monitoring job that watches for anomalies and uses [`User.Inbox.push()`](/api/inbox) to reach the user proactively — the three card kinds working together: 1. A **notice** when a refund spike appears (revised in place as numbers change), 2. An **approval** when the agent has drafted a response and wants a go-ahead, 3. A **connection fix** when the data source it depends on disconnects. Along the way it handles every receipt outcome — including being `capped` — so the skill degrades gracefully instead of erroring. ## The monitor ```typescript theme={null} import { User } from 'lua-cli'; async function checkRefunds() { const spike = await detectRefundSpike(); // your own logic if (!spike) return { status: 'all clear' }; // ── 1. The notice — keyed, so re-runs REVISE instead of re-knocking ── const receipt = await User.Inbox.push({ title: `${spike.count} refunds on ${spike.sku} within the hour`, body: 'All from the same checkout flow — want a summary before it spreads?', deeplink: `https://yourdashboard.example.com/refunds?sku=${spike.sku}`, priority: spike.count > 5 ? 'urgent' : 'high', key: `refund-spike-${spike.sku}`, // stable per incident }); // ── 2. Handle the receipt — capped is a NORMAL outcome ── if (receipt.outcome === 'capped') { // Budget spent or the org has disabled pushes. Don't retry, don't // throw — carry the finding in your run summary instead. return { status: 'anomaly found', note: `Refund spike on ${spike.sku} (${spike.count} in the last hour). ` + `Inbox push unavailable: ${receipt.reason}`, }; } return { status: 'user notified', outcome: receipt.outcome }; } ``` Run it again while the incident is live and the same card updates silently: ```typescript theme={null} // Ten minutes later, the spike grew — same key, so the card revises: await User.Inbox.push({ title: `9 refunds on ${spike.sku} within the hour`, body: 'Still climbing. Draft response ready when you are.', key: `refund-spike-${spike.sku}`, }); // → { outcome: 'updated' } — the card changed, the user was NOT re-notified ``` ## Asking for a go-ahead When the agent has done the work and needs one human click, push an approval — `approve` without options becomes an Approve / Decline pair, and the user's pick resolves the card: ```typescript theme={null} await User.Inbox.push({ title: 'Pause the checkout flow for SKU-2481?', body: 'The refund spike traces to a broken discount code. I can disable it now.', actions: ['approve'], priority: 'urgent', threadId: currentThreadId, // acting hands off into this conversation }); ``` Or offer real choices: ```typescript theme={null} await User.Inbox.push({ title: 'How should I handle the affected orders?', body: '12 orders hit the broken discount before I caught it.', options: [ { label: 'Refund all 12', description: 'Full refunds, apology email' }, { label: 'Honor the discount', description: 'Keep the orders, eat the margin' }, { label: 'Ask me per order' }, ], }); ``` ## When the data source breaks If the integration your monitor depends on disconnects, don't fail silently — push the fix. The card deep-links the user straight into reconnecting: ```typescript theme={null} try { await fetchRefundData(); } catch (err) { if (isAuthError(err)) { await User.Inbox.push({ title: 'Stripe disconnected', body: "Refund monitoring is blind until it's reconnected.", actions: ['fix'], connection: { type: 'stripe', name: 'Stripe' }, }); return { status: 'blocked on connection' }; } throw err; } ``` ## Design notes * **Key everything that recurs.** A monitor without a `key` mints a new card per run and burns its daily budget by lunch. With a stable key, the whole incident is ONE card that stays current. * **Let `capped` be boring.** Five pushes per day per user per card class is the contract; your skill should have a summary-shaped fallback ready, not a retry loop. * **Don't lean on `urgent`.** It's capped at 2/day (the excess lands as `high`), and inside the user's quiet hours even urgent sends **no notification at all** — the card still lands, and that's what you should count on. * **Test in `lua dev`.** Pushes from dev runs land in your own Inbox, so you can watch the full loop — card, notification, revision, resolution — before your users ever do. # Proactive Send Example Source: https://docs.heylua.ai/examples/proactive-send Schedule agent-initiated outreach with LuaJob + Channels.send ## Overview This recipe shows how to make your agent **reach out on a schedule** — a daily reminder, a follow-up, a status nudge — by combining a [scheduled job](/api/luajob) with the [Channels API](/api/channels). The same pattern works from [webhooks](/api/luawebhook) and tools. ## What It Does * Runs every morning on a cron schedule * Looks up who needs a reminder * Sends each a WhatsApp message with `Channels.send` * Falls back to an approved template when the recipient's 24-hour window is closed * Every send is recorded to the recipient's conversation thread, so replies continue naturally ## Complete Code ```typescript theme={null} import { LuaJob, Channels } from 'lua-cli'; const appointmentReminders = new LuaJob({ name: 'appointment-reminders', description: 'Send next-day appointment reminders every morning', schedule: { type: 'cron', expression: '0 9 * * *' // every day at 9:00 AM }, execute: async () => { const appointments = await getTomorrowsAppointments(); for (const appt of appointments) { try { const result = await Channels.send({ channel: 'whatsapp', to: { userId: appt.userId }, text: `Hi ${appt.name}! Reminder: your appointment is tomorrow at ${appt.time}. Reply here if you need to reschedule.`, options: { whatsapp: { onClosedWindow: 'fail' } } }); console.log(`Reminded ${appt.userId} (delivered: ${result.delivered})`); } catch { // Window closed — start the conversation with an approved template await Channels.whatsapp.sendTemplate({ to: { userId: appt.userId }, templateName: 'appointment_reminder', languageCode: 'en_US', components: [ { type: 'BODY', parameters: [{ type: 'text', text: appt.time }] } ], messageContext: `Reminded ${appt.name} about their appointment tomorrow at ${appt.time}` }); } } } }); export default appointmentReminders; ``` ## Key Concepts Free-form WhatsApp is only allowed within 24 hours of the user's last message. Setting `onClosedWindow: 'fail'` makes `Channels.send` throw when the window is closed, so the `catch` block can send an approved template instead. (Omit the option to let it **queue** automatically — see [Proactive Messaging](/channels/proactive-messaging#the-whatsapp-24-hour-window).) A scheduled job runs outside any conversation, so you address recipients explicitly — here by `to.userId`. You can also target a raw `phoneNumber` or `email` for cold outreach. When you send a template, `messageContext` is the plain-text summary recorded to the thread. When the user replies, your agent sees "Reminded … about their appointment" and continues naturally — not a blank slate. ## Variation: confirm on a webhook event The same `Channels.send` call works from a [webhook](/api/luawebhook) — for example, confirming a payment the moment your payment provider fires its event: ```typescript theme={null} import { LuaWebhook, Channels } from 'lua-cli'; const paymentConfirmation = new LuaWebhook({ name: 'payment-confirmation', description: 'Notify the customer when their payment succeeds', execute: async (event) => { const { customerId, amount } = event.body; await Channels.send({ channel: 'whatsapp', to: { userId: customerId }, text: `Payment of $${amount} received — thank you! 🎉` }); return { notified: true }; } }); export default paymentConfirmation; ``` ## Next steps Full reference for send, sendTemplate, and email.send The model behind agent-initiated messages Define scheduled tasks React to external events # Products Tools Example Source: https://docs.heylua.ai/examples/products Complete CRUD operations for product catalog ## Overview **File**: `src/tools/ProductsTool.ts` Six tools demonstrating complete product management with the Products API. ## Tools Included Find products by query List with pagination Query by attributes Add new products Modify existing Remove products ## Example Tools ### SearchProductsTool ```typescript theme={null} import { LuaTool, Products } from 'lua-cli'; import { z } from 'zod'; export class SearchProductsTool implements LuaTool { name = "search_products"; description = "Search products by name or description"; inputSchema = z.object({ query: z.string().describe("Search query") }); async execute(input: z.infer) { const results = await Products.search(input.query); return { products: results.map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, inStock: p.inStock })), count: results.length }; } } ``` ### CreateProductTool ```typescript theme={null} export class CreateProductTool implements LuaTool { name = "create_product"; description = "Add a new product to the catalog"; inputSchema = z.object({ name: z.string(), price: z.number().positive(), category: z.string().optional(), sku: z.string().optional(), description: z.string().optional() }); async execute(input: z.infer) { const result = await Products.create({ ...input, id: generateId(), // Generate unique ID inStock: true }); return { success: true, productId: result.product.id, message: `Product "${input.name}" created` }; } } ``` ### UpdateProductTool ```typescript theme={null} export class UpdateProductTool implements LuaTool { name = "update_product"; description = "Update product information"; inputSchema = z.object({ id: z.string(), name: z.string().optional(), price: z.number().positive().optional(), inStock: z.boolean().optional() }); async execute(input: z.infer) { const { id, ...updates } = input; await Products.update(updates, id); return { success: true, message: "Product updated successfully" }; } } ``` ### BrowseProductsTool (with Filters) ```typescript theme={null} export class BrowseProductsTool implements LuaTool { name = "browse_products"; description = "Browse and filter products by category, price, or availability"; inputSchema = z.object({ category: z.string().optional().describe("Product category"), minPrice: z.number().optional().describe("Minimum price"), maxPrice: z.number().optional().describe("Maximum price"), inStockOnly: z.boolean().optional().describe("Only show in-stock items"), page: z.number().optional().default(1) }); async execute(input: z.infer) { // Build filter from input const filter: Record = {}; if (input.category) { filter.category = input.category; } if (input.minPrice !== undefined || input.maxPrice !== undefined) { filter.price = {}; if (input.minPrice !== undefined) filter.price.$gte = input.minPrice; if (input.maxPrice !== undefined) filter.price.$lte = input.maxPrice; } if (input.inStockOnly) { filter.inStock = true; } const results = await Products.get({ page: input.page, limit: 10, filter }); return { products: results.map(p => ({ id: p.id, name: p.name, price: `$${p.price.toFixed(2)}`, category: p.category, inStock: p.inStock })), pagination: { page: results.pagination.currentPage, totalPages: results.pagination.totalPages, totalProducts: results.pagination.totalCount } }; } } ``` ## Use Cases ### E-commerce Catalog ```typescript theme={null} // Browse products with pagination const products = await Products.get({ page: 1, limit: 20 }); // Search for specific items (semantic search) const laptops = await Products.search('laptop'); // Filter by category const electronics = await Products.get({ filter: { category: 'Electronics' } }); // Get product details — laptops is ProductSearchInstance, use .products[0] or index directly const product = await Products.getById(laptops.products[0].id); ``` ### Filtering Products ```typescript theme={null} // Filter by price range const affordable = await Products.get({ filter: { price: { $gte: 50, $lte: 200 } } }); // Filter by category and stock status const availablePhones = await Products.get({ filter: { category: 'Phones', inStock: true } }); // Filter by nested fields const appleProducts = await Products.get({ filter: { 'metadata.brand': 'Apple' } }); // Multiple categories const gadgets = await Products.get({ filter: { category: { $in: ['Phones', 'Tablets', 'Watches'] } } }); ``` ### Inventory Management ```typescript theme={null} // Update stock status await Products.update({ inStock: false }, productId); // Update price await Products.update({ price: 899.99 }, productId); // Get out-of-stock products const outOfStock = await Products.get({ filter: { inStock: false } }); // Bulk update by category const electronics = await Products.get({ limit: 100, filter: { category: 'Electronics' } }); for (const product of electronics) { await Products.update({ onSale: true }, product.id); } ``` ### Admin Dashboard ```typescript theme={null} // Get all products const all = await Products.get({ page: 1, limit: 1000 }); // Statistics const totalValue = all.reduce((sum, p) => sum + p.price, 0); const inStock = all.filter(p => p.inStock).length; const categories = [...new Set(all.map(p => p.category))]; // Get low-stock items (custom field) const lowStock = await Products.get({ filter: { stockCount: { $lte: 10 }, inStock: true } }); ``` ### Using save() Method (New!) ```typescript theme={null} // Get product and modify multiple fields const product = await Products.getById('product_123'); // Modify properties directly product.price = 899.99; product.inStock = true; product.category = 'Electronics - Sale'; product.description = 'Updated description'; // Save all changes at once await product.save(); // Much cleaner than calling update()! ``` ### Practical Example: Bulk Price Update ```typescript theme={null} export class BulkDiscountTool implements LuaTool { name = "apply_discount"; description = "Apply discount to products in a category"; inputSchema = z.object({ category: z.string(), discountPercent: z.number() }); async execute(input: z.infer) { // Use filter to get only products in the target category const products = await Products.get({ limit: 1000, filter: { category: input.category } }); let updatedCount = 0; for (const product of products.data) { // Modify and save product.price = product.price * (1 - input.discountPercent / 100); await product.save(); updatedCount++; } return { message: `Discount applied to ${updatedCount} ${input.category} products` }; } } ``` ## What You'll Learn Create, Read, Update, Delete Handle large datasets Semantic search with vector embeddings MongoDB-style queries for structured data ## Next Steps Complete API reference Add products to carts # User Data Tools Example Source: https://docs.heylua.ai/examples/user-data Persistent user storage: state machines, onboarding flows, and preferences ## Overview **File**: `src/tools/UserDataTool.ts` Tools demonstrating the User API as a **persistent per-user storage layer** — from simple profile reads to multi-step onboarding state machines. ## Tools Included ### GetUserDataTool Retrieve current user's information. ```typescript theme={null} import { LuaTool, User } from 'lua-cli'; import { z } from 'zod'; export class GetUserDataTool implements LuaTool { name = "get_user_data"; description = "Retrieve current user's profile information"; inputSchema = z.object({}); async execute(input: any) { const user = await User.get(); return { name: user.name, email: user.email, id: user.id }; } } ``` ### UpdateUserDataTool Update user profile information. ```typescript theme={null} export class UpdateUserDataTool implements LuaTool { name = "update_user_data"; description = "Update user's profile information"; inputSchema = z.object({ data: z.object({ name: z.string().optional(), phone: z.string().optional(), preferences: z.record(z.any()).optional() }) }); async execute(input: z.infer) { const user = await User.get(); const updated = await user.update(input.data); return { success: true, message: "Profile updated successfully", user: updated }; } } ``` ## Use Cases ### Onboarding State Machine (Persistent Across Sessions) The most powerful use of the User API — track multi-step workflows that persist across conversations. If a user leaves mid-onboarding and comes back days later, your agent picks up exactly where they left off. ```typescript theme={null} export class OnboardingStepTool { name = 'onboarding_step'; description = 'Advance the user through onboarding, 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(); // This persists across conversations — the user can leave and come back const step = user.onboardingStep || 'not_started'; switch (step) { case 'not_started': user.onboardingStep = 'collecting_info'; user.onboardingStartedAt = new Date().toISOString(); user.completedSteps = []; await user.save(); return { nextAction: 'Ask for company name and role' }; case 'collecting_info': // Accumulate data across multiple tool calls user.companyName = input.data?.companyName; user.role = input.data?.role; user.onboardingStep = 'selecting_plan'; user.completedSteps = [...(user.completedSteps || []), 'info_collected']; await user.save(); return { nextAction: 'Present plan options' }; case 'selecting_plan': user.plan = input.data?.plan; user.onboardingStep = 'complete'; user.onboardingCompletedAt = new Date().toISOString(); user.completedSteps = [...(user.completedSteps || []), 'plan_selected']; await user.save(); return { message: `Welcome to the ${user.plan} plan, ${user.companyName}!` }; case 'complete': return { message: 'Onboarding already complete!', completedAt: user.onboardingCompletedAt, plan: user.plan }; } } } ``` ### Personalized Greeting ```typescript theme={null} export class GreetUserTool { async execute(input: any) { const user = await User.get(); const hour = new Date().getHours(); const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; return { message: `${greeting}, ${user.name}! How can I help you today?` }; } } ``` ### User Preferences ```typescript theme={null} export class SavePreferencesTool { async execute(input: { theme: string, language: string }) { const user = await User.get(); await user.update({ preferences: { theme: input.theme, language: input.language, updatedAt: new Date().toISOString() } }); return { message: "Preferences saved!" }; } } ``` ### Using save() Method (New!) ```typescript theme={null} export class UpdateProfileTool { async execute(input: { name: string, email: string, phone: string }) { const user = await User.get(); // Modify properties directly user.name = input.name; user.email = input.email; user.phone = input.phone; // Save all changes at once await user.save(); return { message: "Profile updated successfully!", user: { name: user.name, email: user.email, phone: user.phone } }; } } ``` ### Sending Messages to Users (New!) ```typescript theme={null} export class SendOrderUpdateTool { async execute(input: { orderId: string, status: string }) { const user = await User.get(); // Send notification to user await user.send([ { type: "text", text: `Hi ${user.name}! Your order #${input.orderId} is now ${input.status}.` }, { type: "text", text: "Thank you for your purchase!" } ]); return { message: "Notification sent to user" }; } } ``` ### Sending Images and Files (New!) ```typescript theme={null} export class SendReceiptTool { async execute(input: { orderId: string, receiptData: string, qrCode: string }) { const user = await User.get(); // Send receipt with QR code await user.send([ { type: "text", text: `Receipt for order #${input.orderId}` }, { type: "image", image: input.qrCode, mediaType: "image/png" }, { type: "file", data: input.receiptData, mediaType: "application/pdf" } ]); return { message: "Receipt sent to user" }; } } ``` ## What You'll Learn Store onboarding progress, workflow state, and custom data across sessions Build multi-step flows that resume where the user left off Using Lua's built-in User API Simpler workflow for multiple changes Send proactive notifications Send images and files to users ## Next Steps Complete API documentation See CRUD operations # Weather Tool Example Source: https://docs.heylua.ai/examples/weather External API integration without requiring an API key ## Overview **File**: `src/tools/GetWeatherTool.ts` The Weather Tool demonstrates external API integration using the free Open-Meteo API (no API key required). ## What It Does * Fetches real-time weather for any city worldwide * Two-step process: geocoding + weather data * Error handling for invalid cities * No API key or authentication required ## Complete Code ```typescript theme={null} import { LuaTool } from 'lua-cli'; import { z } from 'zod'; export default class GetWeatherTool implements LuaTool { name = "get_weather"; description = "Get current weather conditions for any city"; inputSchema = z.object({ city: z.string().describe("City name (e.g., 'London', 'Tokyo')") }); async execute(input: z.infer) { // Step 1: Convert city name to coordinates const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(input.city)}&count=1`; const geoRes = await fetch(geoUrl); const geoData = await geoRes.json(); if (!geoData.results?.[0]) { throw new Error(`City not found: ${input.city}`); } const { latitude, longitude, name } = geoData.results[0]; // Step 2: Get weather for coordinates const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`; const weatherRes = await fetch(weatherUrl); const weatherData = await weatherRes.json(); return { city: name, temperature: weatherData.current_weather.temperature, windSpeed: weatherData.current_weather.windspeed, weatherCode: weatherData.current_weather.weathercode }; } } ``` ## Key Concepts ### 1. Two-Step API Call **Why?** Weather APIs need coordinates, but users provide city names. ```typescript theme={null} // Step 1: City name → Coordinates const geoData = await fetch(geocodingUrl); const { latitude, longitude } = geoData.results[0]; // Step 2: Coordinates → Weather const weatherData = await fetch(weatherUrl); ``` ### 2. URL Encoding Always encode user input in URLs: ```typescript theme={null} const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(input.city)}&count=1`; ``` ### 3. Error Handling Check if city exists before proceeding: ```typescript theme={null} if (!geoData.results?.[0]) { throw new Error(`City not found: ${input.city}`); } ``` ### 4. Structured Return Return organized data, not raw API response: ```typescript theme={null} return { city: name, // Clean city name temperature: data.temp, // Extract what's needed windSpeed: data.wind // No extra data }; ``` ## Testing ```bash theme={null} lua test ``` Try different cities: * `London` - Should work * `Tokyo` - Should work * `New York` - Should work * `XYZ123` - Should fail gracefully ## Customization Ideas ### Add Temperature Units ```typescript theme={null} inputSchema = z.object({ city: z.string(), units: z.enum(['metric', 'imperial']).default('metric') }); // In URL const weatherUrl = `...&temperature_unit=${units === 'imperial' ? 'fahrenheit' : 'celsius'}`; ``` ### Add Weather Recommendations ```typescript theme={null} return { ...weatherData, recommendation: temperature < 10 ? "🧥 Bring a warm jacket" : temperature < 20 ? "👕 Light jacket recommended" : "☀️ T-shirt weather!" }; ``` ### Add Forecast ```typescript theme={null} const forecastUrl = `...&forecast_days=7`; const forecast = await fetch(forecastUrl); return { current: {...}, forecast: forecast.daily }; ``` ## What You'll Learn How to call external REST APIs Graceful error messages Converting API responses to clean output Safely encoding user input in URLs ## Next Steps Learn platform API usage See authenticated external API # Actions Component Source: https://docs.heylua.ai/formatting/actions Interactive buttons and quick replies ## Overview The **Actions** component presents clickable action buttons that users can tap to take next steps. Call-to-action buttons, quick replies, navigation options after showing information ## Format ``` ::: actions - Action Label 1 - Action Label 2 - Action Label 3 ::: ``` ## Requirements * ✅ Maximum **10 actions** * ✅ Clear, action-oriented language * ✅ Only actions your agent can handle * ✅ Most relevant first ## Examples ### After Product Listing ``` ::: actions - Add to Cart - View Details - Compare Products - Check Store Availability - Save for Later ::: ``` Action buttons rendered in the Lua Pop web widget *Action buttons rendered in the Lua Pop web widget.* ### After Hotel Rooms ``` ::: actions - Book This Room - See More Photos - Check Other Dates - View Amenities - Get Directions ::: ``` ### After Support Article ``` ::: actions - Mark as Solved - Still Need Help - Read Related Articles - Contact Human Agent ::: ``` ## How to Configure ```typescript theme={null} persona: ` After showing options to users, ALWAYS provide relevant actions: ::: actions - [Primary action] - [Secondary action] - [Alternative action] ::: Examples: - After products: "Add to Cart", "View Details" - After rooms: "Book Now", "See Photos" - After articles: "Read More", "Share" ` ``` ## Best Practices * Use verbs: "Book", "View", "Check" * Be specific: "Add to Cart" not "Continue" * Limit to 5-7 for best UX * Order by importance ## Next Steps Learn about displaying URLs # Documents Component Source: https://docs.heylua.ai/formatting/documents Send downloadable files and attachments to users ## Overview The **Documents** component sends downloadable files to users — PDFs, contracts, invoices, images, and any other file type. Each document displays as a clickable card with a filename and opens in a new tab. ## Format ``` ::: documents [Display Name](https://url-to-file.pdf) filename:filename.pdf mime:application/pdf ::: ``` You can include multiple files in a single block: ``` ::: documents [Display Name 1](https://url-to-file1.pdf) filename:file1.pdf mime:application/pdf [Display Name 2](https://url-to-file2.docx) filename:file2.docx mime:application/vnd.openxmlformats-officedocument.wordprocessingml.document ::: ``` ### Fields | Field | Description | | ---------------- | -------------------------------------------------------- | | `[Display Name]` | The label shown to the user on the file card | | `(url)` | Direct URL to the file (use the Lua CDN for uploads) | | `filename:` | The actual filename with extension (e.g. `contract.pdf`) | | `mime:` | The MIME type of the file (e.g. `application/pdf`) | ## Example ``` ::: documents [Loan Agreement](https://cdn.heylua.ai/2a46d577-77d1-490a-9148-b11243000da8.pdf) filename:loan_agreement.pdf mime:application/pdf [Terms & Conditions](https://cdn.heylua.ai/5f32c891-aa2d-41bc-9e12-c33410000ec9.pdf) filename:terms_and_conditions.pdf mime:application/pdf ::: ``` Document cards rendered in the Lua Pop web widget *Document cards rendered in the Lua Pop web widget.* ## Common MIME Types | File Type | MIME Type | | ------------- | ------------------------------------------------------------------------- | | PDF | `application/pdf` | | Word (.docx) | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | | Excel (.xlsx) | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | | PNG image | `image/png` | | JPEG image | `image/jpeg` | | CSV | `text/csv` | ## Configure in Persona ```typescript theme={null} persona: ` After completing a loan application, send the agreement documents using: ::: documents [Loan Agreement](url) filename:loan_agreement.pdf mime:application/pdf ::: Always confirm the user has received the documents. ` ``` ## Upload Files with the CDN Use the Lua CDN API to upload files before referencing them in the documents component. The CDN returns a permanent URL you can include in the block. Upload files and get permanent URLs ## Best For * Loan agreements and contracts * Invoices and receipts * Policy documents * Onboarding packets * Reports and summaries * Any user-facing file download Next: Payment links # Flow Component Source: https://docs.heylua.ai/formatting/flow Send WhatsApp Flows as interactive forms inside the chat ## Overview The **Flow** component sends a [WhatsApp Flow](https://developers.facebook.com/docs/whatsapp/flows) as an interactive message with a CTA button. When the user taps the button, a multi-screen form opens inside WhatsApp — they fill it in and submit it, all without leaving the chat. Flows are built in **WhatsApp Manager** (not in Lua). The agent just needs to know the Flow ID to send it. **WhatsApp only** — This component only works on WhatsApp. On other channels, the raw `:::` block will be sent as plain text. A WhatsApp Flow: the message with a CTA button, the multi-screen form, and the confirmation *A WhatsApp Flow in action — the message with its CTA button, the multi-screen form the user fills in, and the confirmation. Example from [Meta's WhatsApp Flows documentation](https://developers.facebook.com/docs/whatsapp/flows).* ## Format ``` ::: flow flow_id= flow_cta=