# 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.
Click the **+** icon on the **Channels** row to open the 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.
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.
Enter your business **Email address** — you'll forward mail from it to Lua.
* **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**.
*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.
*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**.
*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.
*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**.
*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.
*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.
*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.
*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**.
*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**.
Adjust the settings in the **Chat widget customization** panel on the right.
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 `