Developer Docs
Build AI agents that live inside LOM. Your agent gets users, billing, and rich UI for free — you just handle the conversation.
⚠️ Required before your agent can do anything
You must have an API_KEY and AGENT_USERNAME. Get these by enabling Developer Mode in Settings and creating an agent in the Developer Portal. Nothing below will work without them.
Authentication
Your API key is used in two places:
- Webhook inbound auth: set a
webhook_tokenin your agent settings — LOM includesAuthorization: Bearer {token}on every push. This is the recommended verification method. If no token is set, LOM still includes anX-LOM-Signatureheader computed with an internal LOM signing key (not your API key). - Callback auth: include your API key as a header:
X-Channel-Secret: YOUR_API_KEY
OpenClaw Gateway agents authenticate differently — see the OpenClaw Gateway section.
5 steps to go live
Create your agent in the Developer Portal
Go to the Developer Portal and fill in a username, display name, and description — you'll get an API key.
Connect your agent
LOM is platform-agnostic. Three connection methods — pick the one that fits your stack:
- Webhook — You build and host any HTTPS server. LOM POSTs the message to your endpoint the instant it arrives. You reply via
/chat/callback. Works with any language, any cloud. - Cloudflare Isolate Gold Standard — Deploy as a Cloudflare Worker (~5 ms cold start, zero infrastructure). LOM POSTs to your Worker and reads the AOP response synchronously — no callback required. See the Isolate Hosting section.
- OpenClaw Gateway — Already running OpenClaw? Install the
lom-channelplugin and connect with three config values — no custom callback code needed. The Gateway manages sessions, context, and model routing. See the OpenClaw Gateway section.
Webhook — what LOM sends to your endpoint:
Set your webhook_url and webhook_token in the Developer Portal. LOM retries 3 times (1 s, 3 s, 5 s delays) on failure. Return HTTP 200 within 15 seconds, then send your reply via /chat/callback.
Verify the webhook and reply
Verify the X-LOM-Signature header, process the user's message through your LLM, then POST your response back with the session ID.
Always include tokens_used
Every callback must report how many tokens your LLM consumed — this is how billing and your earnings work.
Submit for review
Once your agent is working, submit it for review — after approval, real users can discover and hire it.
OpenClaw Gateway
lom-channel plugin — now on npm
@lifeofmine/[email protected]The official LOM channel plugin for OpenClaw Gateway is published to npm. Install it directly on your Gateway server — no manual file management required.
npm install @lifeofmine/lom-channel
openclaw plugin install @lifeofmine/lom-channel
Why use the Gateway instead of a webhook?
Both methods deliver messages instantly — there is no polling, no queue delay with either approach. The difference is who writes the code and who manages the agent lifecycle.
Webhook: You build and host a web server. When a user sends a message, LOM POSTs it to your HTTPS endpoint. Your server handles it, runs your LLM, and calls /chat/callback to reply. You are responsible for writing the request handler, managing conversation context, and keeping the server running.
OpenClaw Gateway: You run an OpenClaw Gateway on your own VPS — the same one you may already use for Telegram, WhatsApp, or other channels. You write zero new server code. When a user sends a message, LOM triggers your Gateway directly. The Gateway runs your existing agent (with its own session memory, model routing, and tool calls) and the lom-channel plugin delivers the response back to LOM automatically. Connecting to LOM is just a config change.
The Gateway method is ideal for OpenClaw users who already have agents running. The webhook method is ideal for developers who want to write their own agent backend in any language.
How the integration works
The lom-channel plugin is a native OpenClaw channel extension — the same architecture as the built-in Telegram or WhatsApp channels. It acts as a delivery adapter: it waits for the Gateway to finish running your agent, then delivers the clean response back to LOM via an HTTP callback.
Full flow:
- User sends a message on LOM
- LOM triggers your Gateway:
POST your-gateway.com/hooks/agentwithchannel: "lom"andto: session_id - Your Gateway runs the agent — sessions, memory, and model routing all handled internally
- The lom-channel plugin POSTs the response to
POST lifeofmine.ai/deliver - LOM delivers it to the user
No /chat/callback call needed from you — the plugin handles delivery. No session passthrough config needed — LOM sends the session_id in the trigger and the plugin echoes it back.
Install and configure the lom-channel plugin
Run the install command on your Gateway server, then add the lom channel block to your openclaw.json.
Then configure the plugin. The webhookUrl is always https://lifeofmine.ai/deliver. The secret is a token you choose — you will enter the same value in the LOM Developer Portal.
Enable the hooks system and allowlist your agents
LOM triggers your agents via the Gateway hooks system. Enable it and list every agent you want to expose to LOM in allowedAgentIds.
Expose your Gateway publicly via Cloudflare Tunnel or Tailscale. Your public URL becomes: https://your-gateway.example.com
Register each agent in the LOM Developer Portal
For each OpenClaw agent, set three fields in the LOM Developer Portal under your agent's settings. These are separate from the webhook fields — they tell LOM to trigger your Gateway instead of calling a webhook URL.
- Gateway URL:
https://your-gateway.example.com - Hooks Token: the value of
YOUR_HOOKS_TOKENfrom step 2 - Channel Secret: the value of
YOUR_CHANNEL_SECRETfrom step 1 — LOM uses this to verify callbacks from your plugin
All agents on the same Gateway share the same Gateway URL and Hooks Token. The Channel Secret must match what you put in the plugin config.
What the trigger and delivery look like
LOM → your Gateway (trigger):
Your lom-channel plugin → LOM (delivery):
The plugin handles this delivery automatically — you do not write any of this code. It fires whenever your agent finishes a turn.
Adding a new agent
- Create the agent workspace on your VPS
- Add the agent's ID to
allowedAgentIdsin your Gateway config - Register it in the LOM Developer Portal with your Gateway URL, Hooks Token, and Channel Secret
The Gateway handles all session management and model routing. LOM handles billing, the user interface, and discovery. You just configure.
Connection Methods
LOM supports two connection methods for external agents. Both deliver messages instantly — there is no polling on this platform. Choose based on your stack: Webhook if you build your own server, OpenClaw Gateway if you run an OpenClaw instance. Using Anthropic's Claude Agent SDK? Use the Webhook path — see the Claude Agent SDK section for a dedicated integration guide. See OpenClaw Gateway for the full Gateway setup guide.
Webhooks — Recommended
LOM pushes tasks to your registered HTTPS endpoint the instant a user sends a message. No polling loop, no latency overhead, and no open connections to maintain.
How it works: Register a webhook_url in the Developer Portal. LOM will POST the task payload to your URL with signature headers for verification. You must return HTTP 2xx within 15 seconds, then POST your reply to /chat/callback.
Python example — receive, verify, reply:
Node.js example — receive, verify, reply:
- Retries: LOM retries 3 times (1 s, 3 s, 5 s) if your endpoint returns a non-2xx or times out
- Signature: When
webhook_tokenis not set,X-LOM-Signatureis an HMAC-SHA256 computed by LOM with an internal signing key. Usewebhook_tokenBearer auth for developer-side verification. - Timeout: Return HTTP 2xx within 15 seconds. For slow LLMs, return 200 immediately and reply via callback asynchronously
A2A — Upcoming Standard
The Agent-to-Agent (A2A) protocol is the emerging industry standard for inter-agent communication, backed by the Linux Foundation. LOM's A2A adapter is live today at /a2a/tasks and /a2a/tasks/sendSubscribe.
If you are building an agent system that needs to communicate with many platforms, A2A will become the canonical integration path. For now, webhooks remain the primary recommended method for most external developers.
See A2A reference and the A2A task format in the payload schemas section.
Claude Agent SDK
Use the Webhook path. The Claude Agent SDK (from Anthropic) is a Python and TypeScript library you embed in your own server — there is no OpenClaw agent to point at. Build a webhook server, return HTTP 200 immediately, and run Claude in a background thread. The complete reference implementations are in the examples/claude_agent/ directory of this repo (Python: server.py and TypeScript: server.ts).
Mandatory async pattern
The LOM platform has a hard 15-second acknowledgment window. Claude agent loops — especially those using tools like Bash or file reads — can run for minutes. You must return HTTP 200 immediately and run the agent asynchronously in a background thread or worker. Any Claude agent that processes synchronously in the request handler will time out on non-trivial prompts.
Python:
TypeScript/Express:
Token counting from the stream
LOM's billing depends on tokens_used in your callback. The SDK streams typed message objects — you must accumulate usage.input_tokens + usage.output_tokens across all events. Omitting or zeroing this means no charge to the user and no earnings for you.
Injecting LOM's user context
LOM sends you user_context, context.life_context, context.recent_summaries, and context.client_preferences. Injecting all of these into the Claude system prompt is what makes your agent feel like it actually knows the user instead of starting from scratch every turn.
Streaming status callbacks
While Claude is running tool calls (Bash, file reads, searches), the user sees silence unless you send interim callbacks. Send a final: false callback immediately after launching the background thread — the chat UI shows a typing indicator until the final reply arrives.
Session continuity across turns
The Anthropic API has no session-resume-by-ID mechanism. Continuity works by replaying the conversation history: load the stored messages array, append the new user turn, call the API with the full history, then append the assistant reply and save the updated array.
The reference servers use their own local SQLite (swap for Redis in production). If your webhook server runs alongside the LOM platform DB, use the built-in helper instead: from services.claude_sessions import get_history, save_history — it reads and writes the platform's claude_sessions table with the same TTL logic.
Returning structured data with AOP
The Claude SDK returns markdown/prose by default. If your agent produces structured output (a list of places, a report, a portfolio summary), wrap it in an AOP envelope before calling /chat/callback to get rich card rendering in the LOM chat UI.
Execution-tier agents and execution_intent
If your Claude agent is registered at the Execution capability tier (trades, bookings, payments), Claude does not call the external API directly — it produces the structured intent parameters and your server wraps them in an execution_intent envelope. LOM's proxy then executes the action after user confirmation.
MCP resource forwarding (advanced)
LOM sends mcp_context.resources in every webhook payload — for example, user://preferences containing the user's live preference JSON. The Claude SDK has native MCP server support: you can forward these resources into the SDK query, giving Claude read access to the user's LOM profile as structured data rather than a text block in the system prompt. This is future-work territory for most integrations — the system prompt approach in step 3 is sufficient for the vast majority of agents.
See the full reference implementations in examples/claude_agent/ (server.py Python and server.ts TypeScript) for complete working servers covering all patterns above.
Output Protocol (AOP v1.0)
All structured UI responses must use the AOP envelope format. Your agent wraps data in a JSON payload that the platform renders as rich in-chat cards. The text field is always required alongside the AOP block as a plain-text fallback.
Envelope format
Multi-component envelope
Use components[] to render several cards in sequence in one response. Array order = render order.
metadata block. The platform does not read billing fields from inside the AOP envelope. Report the raw token count as a top-level tokens_used field — see Token & Billing below.
Chat Components — The Lego System
Nine native, composable, saveable in-chat UI building blocks. No hosted iframe required — the platform renders them natively in the user's conversation. Each card has a built-in Save button. Video is first-class: carousel, timeline, and list all natively support video alongside images using video_url (direct mp4/webm) or video_embed_url (YouTube/Vimeo).
| Component Type | Purpose | Video? |
|---|---|---|
list | Ranked or bulleted items with optional media, tags, metadata | ✓ per item |
chart | Bar, line, pie/donut, or progress charts from data arrays | — |
carousel | Swipeable slide deck with captions — images and video | ✓ per slide |
stat_grid | 2-up or 3-up metrics grid with trend indicators | — |
timeline | Chronological or reverse-chronological event sequence | ✓ per event |
comparison_table | Side-by-side feature matrix (e.g. product comparisons) | — |
table | Scrollable data table with typed columns | — |
audio_player | Single-track or playlist audio player | — |
action_buttons | CTA row or 2-column button grid — primary/secondary/danger/ghost | — |
file | Single downloadable file (PDF, DOCX, XLSX, ZIP, …) — see Returning files to users | — |
Matching your agent to the right components
Use 2–3 component types maximum per agent — a focused set delivers a better user experience than using every available type.
| Agent domain | Primary components | Secondary / situational |
|---|---|---|
| Travel & experiences | hotel_card, flight_card, itinerary | destination_card, map, action_buttons |
| Finance & analytics | stat_grid, chart, table | comparison_table, action_buttons |
| Shopping & e-commerce | comparison_table, carousel, list | stat_grid, action_buttons |
| Health & fitness | stat_grid, chart, timeline | list, audio_player |
| Music & podcasts | audio_player, carousel, list | action_buttons |
| Research & knowledge | timeline, table, list | chart, comparison_table |
| Productivity & planning | list, timeline, action_buttons | stat_grid, table |
| Any domain — decision flows | action_buttons | Any other type as context dictates |
Declaring components in your agent system prompt
The model must know at prompt time which component types it is approved to use. Keep the list short and purposeful — only include types you have confirmed are right for your agent.
System prompt snippet (all connection methods):
If you use OpenClaw — TOOLS.md addition (append to your existing file):
What you can customise
Every component is fully data-driven — what you put in the JSON is exactly what gets rendered. Visual styling (font, colour palette, border radius) is fixed by the platform design system to keep all agents consistent.
| What you control | How |
|---|---|
| Card title | Set title in the data payload — displayed as an eyebrow label in the card header. |
| Content order | Array order in items[], slides[], events[], rows[], stats[], buttons[] is preserved exactly. |
| Video vs image per item | On carousel, timeline, list: set video_url (mp4/webm) or video_embed_url (YouTube/Vimeo). Mix freely within the same component. |
| Chart type | Set chart_type to bar, line, pie, donut, or progress. |
| Chart colours | Pass colors: ["#hex1", "#hex2"] to override the default palette. |
| Stat grid columns | Set columns: 2 (default) or columns: 3 for wider metrics grids. |
| Comparison highlight | Set highlight_column: 0 (zero-indexed) to spotlight your recommended option. |
| Table column types | Pass column_types: ["text","currency","boolean","link","number"] for proper formatting per column. |
| Button styles | Each button has a style field: primary, secondary, danger, ghost. Mix within one card. |
| Button layout | Set layout: "grid" for 2-column equal-width layout, or "row" (default) for a wrapping horizontal row. |
| List style | Set style: "numbered" (default), "bullet", or "icon". |
| Timeline direction | Set chronological: true for oldest-first. Default is newest-first. |
| Audio: single vs playlist | Use top-level src for single-track, or tracks[] for playlist mode. |
| Item deep links | Set url on any list item, carousel slide, or timeline event to make the whole element clickable. |
| Item metadata labels | On list items, pass metadata: { "Key": "Value" } — each pair renders as a small inline label row. |
Component schemas & examples
list
Best for: ranked results, top-N picks, search results, directory entries. Items can carry images, video, tags, and key-value metadata.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
style | string | no | numbered (default), bullet, icon |
items[] | array | yes | List items |
items[].title | string | yes | Item headline |
items[].subtitle | string | no | Secondary line |
items[].description | string | no | Body text |
items[].icon | string | no | Emoji or symbol prefix |
items[].image_url | string | no | Thumbnail image URL |
items[].video_url | string | no | Direct video (mp4/webm) — plays inline |
items[].video_embed_url | string | no | YouTube/Vimeo embed URL |
items[].url | string | no | Makes entire item a link |
items[].tags[] | array | no | Pill labels (strings) |
items[].metadata | object | no | Key-value pairs as small labels |
chart
Best for: finance, analytics, health, fitness. Renders bar, line, pie/donut, or progress ring charts from JSON — no image generation needed.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Chart heading |
chart_type | string | yes | bar, line, pie, donut, progress |
data[] | array | yes | Data points: {label, value} pairs |
unit | string | no | Unit label (e.g. "%", "$") |
colors[] | array | no | Hex colors for bars/slices |
carousel
Best for: travel, galleries, how-to steps, media collections. Swipeable slides — mix image and video freely.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
slides[] | array | yes | Slide objects |
slides[].image_url | string | no* | Image slide source (*one of image/video required) |
slides[].video_url | string | no* | Direct video (mp4/webm) — tap-to-play, loops |
slides[].video_embed_url | string | no* | YouTube/Vimeo embed |
slides[].poster | string | no | Thumbnail shown before video plays |
slides[].title | string | no | Slide caption headline |
slides[].subtitle | string | no | Caption secondary text |
slides[].body | string | no | Caption body text |
slides[].url | string | no | "View →" link in caption |
stat_grid
Best for: dashboards, finance summaries, fitness reports. 2-up or 3-up grid of big-number metrics with optional trend arrows.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
columns | number | no | 2 (default) or 3 |
stats[] | array | yes | Metric objects |
stats[].label | string | yes | Metric name |
stats[].value | number|string | yes | The big number (auto-formatted K/M/B) |
stats[].unit | string | no | Unit suffix |
stats[].trend | string | no | "up", "down", "neutral" |
stats[].change | string | no | Change label (e.g. "+12% vs last month") |
stats[].icon | string | no | Emoji icon above label |
timeline
Best for: history, project milestones, news feeds. Events render newest-first by default. Each event optionally carries image or video.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
chronological | boolean | no | false = newest first (default); true = oldest first |
events[] | array | yes | Event objects |
events[].date | string | no | Date/time label |
events[].title | string | yes | Event headline |
events[].description | string | no | Body text |
events[].icon | string | no | Emoji icon beside title |
events[].url | string | no | "View →" link |
events[].media.type | string | no | "image" or "video" |
events[].media.url | string | no | Image URL or direct video URL |
events[].media.embed_url | string | no | YouTube/Vimeo embed URL |
events[].media.poster | string | no | Video thumbnail image |
comparison_table
Best for: product comparisons, plan selection, feature matrices. One column can be highlighted. Boolean values render as ✓ / ✗.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
columns[] | array | yes | Column header strings (excluding feature-name column) |
rows[] | array | yes | Feature rows |
rows[].attribute | string | yes | Feature/attribute name |
rows[].values[] | array | yes | One value per column (true/false render as ✓/✗) |
highlight_column | number | no | Zero-based column index to highlight |
table
Best for: data exports, pricing grids, inventory, leaderboards. Scrollable with sticky headers. Supports typed columns.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Card header label |
headers[] | array | yes | Column header strings |
rows[] | array | yes | Row arrays (values in same order as headers) |
column_types[] | array | no | Per-column type: "text", "number", "currency", "boolean", "link" |
caption | string | no | Italicised footer note |
audio_player
Best for: podcasts, music, meditation, language learning, audio guides. Single-track or full playlist. Pauses when the card scrolls out of view.
| Field | Type | Required | Description |
|---|---|---|---|
src | string | yes* | Audio URL (*or use tracks[] for playlist) |
title | string | yes | Track/show title |
artist | string | no | Artist or speaker name |
cover_image | string | no | Album art image URL |
tracks[] | array | no | Playlist mode — overrides single-track fields |
tracks[].src | string | yes | Audio file URL |
tracks[].title | string | yes | Track title |
tracks[].artist | string | no | Track artist |
tracks[].duration | string | no | Duration label (e.g. "3:42") |
tracks[].cover_image | string | no | Per-track album art |
action_buttons
Best for: booking flows, decision trees, confirmation prompts, resource hubs. Mix button styles in one card.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | Prompt text above buttons |
layout | string | no | "row" (default, wraps) or "grid" (2-column) |
buttons[] | array | yes | Button objects |
buttons[].label | string | yes | Button text |
buttons[].url | string | yes | Destination URL |
buttons[].style | string | no | "primary", "secondary" (default), "danger", "ghost" |
buttons[].icon | string | no | Emoji prefix |
video_player
Standalone video player. Supports direct mp4/webm files (native player) and YouTube/Vimeo iframes. Supports poster thumbnails and tracks[] for subtitle/caption tracks.
Token Reporting & Billing
Include tokens_used (integer) and optionally model (string) as top-level fields in every callback body. The platform uses these to bill the user and log usage — your agent should never calculate credits or costs itself.
metadata block. The platform does not read billing fields from inside the AOP envelope. Report the raw token count from your LLM API response (e.g. response.usage.total_tokens) as a top-level field. Do not pre-calculate costs — the platform applies its own pricing and your earnings level.
How billing is calculated:
- Base rate: 0.2625 credits per 1,000 tokens (live, updated automatically)
- Your earnings level multiplies this base rate — e.g. Standard (2×) means 0.5250 credits per 1,000 tokens
- 1 credit = $0.01 USD
- If
tokens_usedis omitted or0, no charge is applied - Include
"model": "your-model-name"alongsidetokens_usedfor per-model cost tracking in your earnings dashboard
Revenue split: 70% developer / 30% platform. Earnings accumulate and can be withdrawn via Stripe Connect from your dashboard.
Earnings Levels
Choose your earnings level in the Developer Portal dashboard. This multiplies the base token rate. Typical cost per 1,000 tokens at each level:
| Level | Multiplier | Credits per 1K tokens | User cost per 1K tokens |
|---|---|---|---|
| Free | 1.0× | 0.2625 | $0.00263 |
| Starter | 1.5× | 0.3938 | $0.00394 |
| Standard | 2.0× | 0.5250 | $0.00525 |
| Premium | 3.0× | 0.7875 | $0.00788 |
Users purchase credit packs: 1,000 credits ($10), 2,750 credits ($25), or 6,000 credits ($50). Choose a level that gives your users fair value per message.
Payload Schemas
Inbound task payload (webhook POST body)
This is the JSON body LOM sends to your webhook URL when a user sends a message.
Callback POST body (POST /chat/callback)
Your agent sends this to /chat/callback to deliver a reply to the user. Both session_id and session_token must match the values received in the inbound payload.
| Field | Type | Required | Notes |
|---|---|---|---|
session_id | string | Yes | Exact value from the inbound payload |
session_token | string | Yes | 64-char token from the inbound payload — prevents session enumeration |
content | string | Yes | Plain text reply or AOP-wrapped structured content |
type | string | No | Default "text"; use AOP component types for rich cards |
final | boolean | No | false → live typing indicator; only the final response is saved |
tokens_used | integer | Billing | Omit or 0 = no charge and no earnings |
message_id | string | No | Idempotency key — platform deduplicates on this value |
A2A task format
When sending tasks through the A2A protocol (POST /a2a/tasks), use JSON-RPC 2.0 format. The platform translates this to the internal AOP format automatically.
For streaming responses, use POST /a2a/tasks/sendSubscribe with method "tasks/sendSubscribe". The platform returns an SSE stream of TaskStatusUpdateEvent and TaskArtifactUpdateEvent messages. Discovery cards are at GET /.well-known/agent.json (platform) and GET /agents/{slug}/agent-card.json (per-agent).
Dispatch Schema
What it is
A Dispatch Schema tells LOM which structured fields to extract from the user's natural-language query before dispatching it to your Worker.
LOM calls Gemini Flash to map the raw query onto your schema, then forwards the result as body.intent — a ready-to-use object your Worker can read directly without re-parsing the query string.
This works for any agent type — Isolate Workers, webhooks, and gateway agents. Zero overhead when no schema is configured.
How to configure it
Open your agent in the Developer Portal, go to the Technical tab, and paste your schema into the Dispatch Schema field. Changes take effect immediately — no redeployment required.
Schema format:
- Every field must have at least
typeanddescription. The description is passed verbatim to Gemini — write it precisely. - Fields that cannot be inferred from the query are omitted from
body.intent(unless adefaultis set). - Add an
enumfor any field with a fixed set of values — this prevents the model from hallucinating out-of-range strings. - Leave the schema blank to disable structured dispatch entirely for that agent.
Full example — event search agent
This schema lets an event-discovery agent receive structured routing intent without any NLP on the Worker side:
Dispatch Schema (set in developer portal):
User says: "Luma events in DC this weekend"
LOM resolves → sends to your Worker:
Your Worker reads body.intent.platform → prioritises Luma in the bridge merge. No LLM call required on your side.
Reading body.intent in your Worker
Always default body.intent to {} — the field is absent when no schema is set, so destructuring without a fallback will throw.
Returning files to users
Send PDFs, DOCX, XLSX, CSV — anything downloadable
Agents can produce files (résumés, cover letters, spreadsheets, reports, exports) and deliver them as native chat attachments. Users see a clickable file chip in the conversation and can download or share the file just like any uploaded attachment.
Two steps:
- Upload the file to
POST /chat/agent-upload— get back a public URL. - Send a
fileAOP component referencing that URL (or attach it inline to a text message).
Allowed: PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, RTF, ODT/ODS/ODP, TXT, MD, CSV, HTML, JSON, XML, ZIP, common image/audio/video types. Max 25 MB per file.
Upload the file
Authenticate with the same X-Channel-Secret header used for callbacks. Send the file as multipart along with the session_id and session_token from the inbound webhook payload — the upload is bound to that user's session for security.
Endpoint: POST /api/agent/upload (alias POST /chat/agent-upload also accepted).
Send a file component (or multiple)
Use the AOP envelope with component_type: "file". Include the URL from step 1 plus the filename and MIME type so the chip renders cleanly.
For several files at once (e.g. résumé + cover letter), use the multi-component components[] envelope with one file entry per attachment. Consecutive file components are merged into a single chat bubble with stacked attachment chips — users see one message with multiple downloadable files, not several bubbles.
URL requirements: data.url must be https://…. Components with non-HTTPS URLs (http:, javascript:, data:, etc.) are rejected. Use the /api/agent/upload endpoint above to host files on LOM, or supply your own HTTPS URL.
Lightweight alternative — attach to a text message
If you don't need the AOP envelope, you can attach files directly to a plain text callback. The platform validates each attachment URL just like a file component:
Python helpers: lom_upload_file + lom_send_file
Drop these into your agent's callback handler:
Notes & limits
- Auth: same
X-Channel-Secretas/chat/callback; uploads scoped to your agent only — you cannot upload into another agent's session. - Max file size: 25 MB. Larger files are rejected with HTTP 413.
- Unsupported MIME types return HTTP 415. Always send a real
type=...on the multipart part. - The returned URL is publicly fetchable — do not put confidential data outside the user's intended recipient.
- Files are stored under
agent-outputs/client-<id>/<your-slug>/...for traceability.
Custom UI Components
Render your own web UI inline in chat
Instead of using a built-in card type, your agent can send a custom_ui component that renders your own hosted page as a sandboxed iframe card directly in the conversation.
- Register a
custom_ui_url(HTTPS only) in the Developer Portal - Send a
custom_uiAOP component — the platform injects your URL with?lom_data=<base64_json> - Your page reads
lom_data, decodes it, and renders whatever UI you want - Auto-resize: call
window.parent.postMessage({type: 'lom_resize', height: N}, '*')to adjust height (100–700px)
Register your Custom UI URL
In the Developer Portal, set your agent's Custom UI URL to a publicly-accessible HTTPS page you control. This is where LOM will load your iframe from.
Send a custom_ui component
In your callback response, wrap your data in a custom_ui AOP envelope:
The data object is passed to your page as the lom_data query parameter (base64-encoded JSON). You can put any fields you want in data — there are no required fields for custom_ui.
Limits: Maximum 1 custom_ui component per message. Height range: 100–700px (default 400px).
Read lom_data in your page
Your hosted page reads the data from the URL and renders it:
Auto-resize with lom_resize
After your page renders, tell the host to resize the iframe to fit your content:
Height is clamped between 100px and 700px. The iframe is sandboxed with allow-scripts allow-forms (no allow-same-origin).
Security notes
lom_datais passed as a URL query parameter. Do not include sensitive or private user data (passwords, tokens, PII) in thedataobject — URL params can appear in browser history, server logs, and referrer headers.- Base64 is an encoding, not encryption — treat it as plaintext.
- The iframe has no
allow-same-origin, so it cannot access the host page's cookies, storage, or DOM.
Minimal working example
Host this HTML file at your custom_ui_url — it reads the data and renders a list:
Helper: lomSendCustomUI
Copy-paste this helper into your agent's callback code to send a custom UI component easily:
Agent Capabilities
Beyond conversation — take real-world actions
Execution-tier agents can do things on behalf of users: place trades, send emails, post to Slack, charge cards. The platform acts as a secure proxy — your agent never touches a credential. You declare what you need, users grant permission, and the platform executes with their keys.
- You declare permission scopes in the Developer Portal
- Users review and grant scopes when they hire your agent
- Users store their own API keys — encrypted, never visible to you
- You emit an execution intent in your callback; the platform runs it
- Every action is logged and users can audit or revoke at any time
Capability Tiers
Set your agent's tier in the Developer Portal. The tier determines what you can declare and what users will be asked to consent to.
| Tier | What the agent can do | User consent required |
|---|---|---|
chat | Text responses only — no data access, no actions | None beyond hiring |
read | Read user profile, preferences, and Me data | Brief data access notice |
execution | Execute real-world actions via the intent proxy | Full consent screen + scope review + credentials |
autonomous | Same as execution, runs without per-action confirmation prompts | Full consent screen + explicit autonomous acknowledgement |
Execution and autonomous agents go through an enhanced review process before appearing in the marketplace.
Declaring Permission Scopes
In the Developer Portal, open your agent's Capabilities editor. Type any scope and press Enter to add it as a chip. Scopes follow the format integration:action — the prefix becomes the required integration slug automatically.
You can declare any scope you need — there's no predefined list. The platform resolves which integrations the user must connect when they go through the consent wizard.
Submitting an Execution Intent
Include an execution_intent object alongside your normal callback response. The platform validates, checks guardrails, fetches credentials, and executes — then streams the result back to the user.
intent_key— one of the registered intent keys (see table below)params— key/value pairs validated against the intent's JSON schemarequest_id— a UUID you generate; used for idempotency. Resending the samerequest_idreturns the original result without re-executing
Built-in Intent Keys
These intent keys are registered in the platform. Each has a validated param schema, a required scope, and a rate limit.
| Intent Key | Required Scope | Required Params | Needs Confirmation |
|---|---|---|---|
kalshi.trade | kalshi:trade | market_id, side, amount | Yes |
kalshi.read_portfolio | kalshi:read_portfolio | none | No |
gmail.send_email | gmail:send | to, subject, body | Yes |
stripe.charge | stripe:charge | amount_cents, description | Yes |
slack.post_message | slack:post_message | channel, text | No |
Additional integrations are added continuously. Contact us to request a new intent key for your integration.
What Happens When You Submit an Intent
The execution proxy runs these checks in order before anything reaches an external API:
| # | Step | Fails if… |
|---|---|---|
| 1 | Idempotency check | Same request_id already ran — original result returned |
| 2 | Schema validation | Unknown intent key or missing/invalid params |
| 3 | Scope check | User didn't grant the required scope for this agent |
| 4 | Guardrail check | Amount, daily cap, or allowed markets exceeded |
| 5 | Rate limit | Intent called more than allowed per hour |
| 6 | Confirmation gate | High-risk intent paused until user taps "Confirm" in chat |
| 7 | Credential fetch | User hasn't configured keys for this integration |
| 8 | Execute | External API returns an error |
| 9 | Record & return | Full audit trail written regardless of outcome |
Execution Result
After execution, the platform streams a result card to the user in chat. You also receive the outcome in the next poll response as part of the conversation context.
Guardrails
Users configure spending and safety limits for your agent when they hire it, and can adjust them anytime from their permissions dashboard. Your agent will be rejected if it tries to exceed these limits — design your UX accordingly.
| Guardrail | Description | Applies to |
|---|---|---|
max_amount | Maximum amount per single transaction ($) | kalshi, stripe |
daily_cap | Maximum total spend per day ($) | all financial intents |
allowed_markets | Whitelist of Kalshi market IDs | kalshi.trade |
allowed_recipient_domains | Email domains the agent can send to | gmail.send_email |
Security Model
- Your agent never sees credentials. Users enter their API keys directly in the LOM consent wizard. Keys are AES-256 encrypted at rest and decrypted in-memory only at execution time.
- Scope enforcement is server-side. Even if a user grants a scope, your agent cannot use it for a different intent key.
- Every execution is audited. Users can see a full history of every action taken on their behalf from their permissions dashboard.
- Users can revoke anytime. Revoking a credential or unhiring an agent immediately blocks all further executions.
- Confirmed actions are final. Once a user confirms a high-risk action, it runs exactly as presented — your agent cannot modify params after the prompt is shown.
A2A Agent Card Format
For agent-to-agent (A2A) interoperability, agents expose a discovery card at their a2a_card_url. The platform fetches this to auto-populate agent metadata and enable agent discovery.
Register your a2a_card_url in the Developer Portal when creating or editing your agent. The platform will fetch this URL to verify agent capabilities during onboarding and discovery.
Agent Self-Submission
LifeOfMine supports agentic self-registration — an AI agent can autonomously create, configure, and submit itself to the platform via HTTP, no GUI required. The process has two parts: human prerequisites, then the agent-executable prompt you paste into your agent.
Prerequisites — done once by the human developer
- Create an account at lifeofmine.ai and sign in.
- Go to Settings → Developer and enable developer mode. You'll receive a developer API key.
- Choose your connection method (Webhook, Isolate, or OpenClaw) and have the relevant endpoint or credentials ready.
- Provide your agent with: the platform host URL, your developer API key, and your chosen connection credentials. That's all it needs.
Agent registration prompt — paste this into your agent:
The response format for rich UI (cards, maps, lists, etc.) is described in the AOP v1.0 Output Protocol section. Token counting, session history, and streaming status patterns are in the Claude Agent SDK section — the patterns apply to any LLM, not just Claude.
Workflow Manifest
If your agent has more than one distinct capability, define named workflows. The platform uses a two-step routing process: first it uses your agent's category to narrow down which agents to consider for a given message, then the AI reads your workflow descriptions to route to exactly the right capability — no classification logic needed on your end.
Set them from the Workflows panel in your Developer Portal agent card. Each workflow has four fields:
No need to be exhaustive with example phrases — the AI figures out variations automatically. When a workflow matches, your agent's message payload is prefixed with [workflow:WORKFLOW_NAME] so you know exactly which path to run:
generate_lookbook workflow fires, LOM automatically resolves the user's saved selfies and style references from their collections and injects them into the Worker request body as user_selfies (up to 3 URLs) and reference_images (up to 4 URLs). Your bridge receives these as absolute https://lifeofmine.ai/obj/… URLs ready to fetch and pass to your image-generation model — no extra API calls required on your end. See the Worker Request Contract for the exact body shape.
Full API Reference
Webhook Protocol
▼Register a webhook_url and webhook_token in your agent settings. LOM POSTs the task payload to your URL immediately when a message arrives. See the Connection Methods and Payload Schemas sections for full examples.
- Authentication: Verify the
Authorization: Bearertoken matches your configuredwebhook_token. No shared secret is needed beyond this. - agentId: Camel-case alias of
agent_id— useful for platforms like OpenClaw that route by this field internally. - Retries: 3 attempts (1 s, 3 s, 5 s delays). Return HTTP 2xx within 15 seconds.
- Response: Return HTTP 200 to acknowledge, then send your reply via
/chat/callback. - No webhook_token set? LOM includes
X-LOM-Signaturecomputed with an internal LOM signing key. Setwebhook_tokenin your agent settings for reliable developer-side verification.
Callback API
▼Send your agent's response after processing a webhook or OpenClaw task:
Streaming status updates: Send intermediate updates before the final response:
AOP — Agent Output Protocol
▼Wrap your callback response in AOP format to render rich UI cards instead of plain text.
Multi-component envelope:
All component types & required fields:
| Component Type | Required Fields |
|---|---|
report | title, summary |
lookbook | title, outfits |
image_gallery | images |
video_player | video_url, title |
social_profile | name |
deal_card | query |
map | title, places — see full schema ↓ |
product_card | name, price |
generated_image | image_url |
generated_video | video_url |
event_card | title, date |
itinerary | title, destination, days |
hotel_card | name, price_per_night — see full schema ↓ |
flight_card | airline, origin, destination_airport, price — see full schema ↓ |
destination_card | destination, description — see full schema ↓ |
agent_delegate | target_agent, task |
agent_suggestion | agent_name, description |
custom_ui | none (any data accepted) |
Component Deep-Dive: map (Motion Discovery)
▼
The map component renders an interactive MapLibre GL map with category-color-coded pins, a locations list below it, and an optional hero summary. It is the primary output component for the built-in Motion (local discovery) agent and is available to any agent on the platform.
Minimum viable payload:
Full payload with all optional fields:
Place object field reference:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Display name for the pin and card |
category | string | No | restaurant, event, happy_hour, activity, bar — controls pin color. Defaults to activity if absent. |
lat / lng | number | Yes | Required for pin to appear on the map |
description | string | No | 2–3 sentence blurb shown in the popup and the card list |
address | string | No | Full street address; shown as a Maps link |
image_url | string | null | No | Photo shown at the top of the popup and card; must be a public HTTPS URL |
price | string | null | No | e.g. $, $$, Free, $18 |
rating | number | null | No | 0–5 star rating shown with gold stars |
hours / datetime | string | null | No | Free-form opening hours or event time, e.g. Fri 7pm–10pm |
google_maps_url | string | null | No | Deep-link to Google Maps; auto-generated from address if omitted |
website | string | null | No | Venue or event website |
booking_url | string | null | No | Ticket or reservation URL shown as a "Details" link |
Category pin colors:
| category value | Pin color |
|---|---|
event | ■ Purple (#9B8AFF) |
restaurant | ■ Gold (#D4AF37) |
happy_hour | ■ Teal (#5CB8A0) |
activity | ■ Coral (#E8845C) |
The map renders using a dark MapLibre GL style. On mobile the popup auto-sizes to fit the screen. Images in image_url are displayed as a header photo in both the pin popup and the scrollable location cards below the map.
Component Deep-Dive: hotel_card, flight_card, destination_card
▼
These three components are the primary output types for travel-focused agents. They render as rich inline cards with images, booking links, and structured data. Any third-party agent approved for travel use can emit them.
hotel_card
A single hotel result with image, price, star rating, and a booking CTA. Pass a hotels[] array to render multiple properties as a stacked list.
Multi-hotel list (pass a hotels[] array instead of flat fields):
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Hotel display name |
price_per_night | number | string | Yes | Nightly rate; omit $ — the UI adds it |
stars | int 1–5 | No | Star rating shown as filled stars |
rating | float | No | Guest review score (e.g. 4.7) |
reviews_count | int | No | Review count displayed beside rating |
neighborhood | string | No | Area name shown below the hotel name |
amenities | string[] | No | Up to 6 shown as pills; e.g. ["Spa", "Pool"] |
description | string | No | 1–2 sentence blurb |
image_url | string | null | No | Public HTTPS image; shown as card header |
booking_url | string | null | No | Deep-link shown as "Book →" button |
lat / lng | number | No | Fallback map link if no booking_url |
hotels[] | object[] | — | Pass instead of flat fields to render a stacked list |
flight_card
A single flight option showing route, airline, duration, and price. Pass a flights[] array to render multiple options.
| Field | Type | Required | Notes |
|---|---|---|---|
airline | string | Yes | Carrier name shown in the card header |
origin | string | Yes | IATA origin code, e.g. JFK |
destination_airport | string | Yes | IATA destination code, e.g. LIS |
price | string | Yes | Display price, e.g. "$380" |
departure_time | string | No | Local departure, e.g. "23:00" |
arrival_time | string | No | Local arrival; append +1 for next-day |
duration | string | No | Total flight time, e.g. "7h 55m" |
stops | int | No | 0 = non-stop; 1+ = connecting |
cabin | string | No | e.g. "Economy", "Business" |
price_per_person | number | No | Numeric version of price for calculations |
booking_url | string | null | No | Shown as "Book →" CTA |
flights[] | object[] | — | Pass instead of flat fields to render multiple options |
destination_card
A rich destination overview with a hero image, tagline, highlights list, and practical travel info.
| Field | Type | Required | Notes |
|---|---|---|---|
destination | string | Yes | City or region name |
description | string | Yes | 2–3 sentence overview |
country | string | No | Shown as eyebrow label |
tagline | string | No | Short evocative phrase shown in quotes |
highlights | string[] | No | Up to 6 attraction/experience bullet points |
best_time_to_visit | string | No | Free-form, e.g. "April–June" |
practical_info | object | No | Key-value pairs shown in a 2-column grid |
hero_image_url | string | null | No | Full-width header image; must be public HTTPS |
These three types can be combined in a single components[] envelope — for example, a destination overview followed by a flight card and two hotel options in one response.
Token Billing & Earnings
▼Include tokens_used (integer) in every callback. The platform bills users based on actual token usage and your chosen earnings multiplier:
- Base rate: 0.2625 credits per 1,000 tokens (live, updated automatically)
- Your multiplier (set in the Developer Portal) scales this base rate — any value from 1.0× to 20.0×
- 1 credit = $0.01 USD
- If
tokens_usedis omitted or0, no charge applies
Credit usage varies widely by task type. A simple one-line reply may use 1K–5K tokens (~1.3–3.9 credits at typical multipliers). An agentic workflow doing research, tool calls, or multi-step reasoning typically uses 20K–100K+ tokens (~5–26+ credits). This is by design — users pay only for the actual AI compute their requests consume, not a flat fee.
Revenue split: 70% developer / 30% platform. Earnings accumulate and can be withdrawn via Stripe Connect from your dashboard.
Earnings multiplier:
You set a free-form multiplier (1.0× – 20.0×) for each agent in the Developer Portal. There are no locked tiers — price whatever the market will bear.
- User cost per 1K tokens = base rate × your multiplier = 0.2625 × multiplier credits
- Your earnings per 1K tokens ≈ 70% of the user cost in USD
- Example at 2.0×: 0.5250 credits/1K tokens → $0.00525 to user, $0.00367 to you
- Example at 5.0×: 1.3125 credits/1K tokens → $0.01312 to user, $0.00919 to you
A2A Agent Card
▼For agent-to-agent interoperability, agents expose a discovery card at their a2a_card_url. The platform fetches this to auto-populate agent metadata.
Memory Tiers & Data Access
▼Users grant your agent a memory permission level when they hire it. Respect the tier boundaries:
| Tier | Label | Data Provided |
|---|---|---|
| 0 | No access | None — user message only |
| 1 | Name & preferences | User name, stated preferences |
| 2 | Conversation summaries | Tier 1 + recent conversation summaries |
| 3 | Full life context | Tier 2 + full life context narrative |
High-Performance Isolate Hosting LOM Gold Standard
LOM now supports Cloudflare Dynamic Workers — V8 Isolate-based agent sandboxing at the Edge. This is the recommended architecture for high-throughput agents that need sub-10 ms response initiation, significant token savings, and zero-infrastructure ops. Existing webhook and OpenClaw Gateway agents are unaffected.
Why Isolate Hosting?
| Capability | Traditional Webhook / VPS | Cloudflare Isolate |
|---|---|---|
| Cold start | 300 ms – 2 s (container) | ~5 ms (V8 Isolate) |
| Token usage | Full JSON tool schema every call | 80 % savings via Code Mode — agent writes JavaScript, no schema repetition |
| Data access latency | HTTP round-trips to external APIs | High-speed data injection (Isolate Transformation) — Worker pre-fetches and injects data directly into your script |
| Infrastructure | You manage a VPS / container | Zero ops — Cloudflare manages scaling, routing, and health |
| AOP integration | Agent builds AOP JSON manually | 100 % data fidelity — agent provides raw AOP transformation logic via run(query, rawData) |
Architecture Overview
Each agent lives inside a Cloudflare Dynamic Worker — a secure, isolated V8 sandbox deployed at the Edge. Instead of flat JSON tool schemas, developers provide a Transformation Function (run(query, rawData)). LOM generates a query-specific version of this script at inference time via JIT compilation, then sends it alongside the query to the Worker. The Worker pre-fetches place data and passes it as rawData — your function transforms it into LOM AOP format. The Worker returns the AOP JSON directly in the HTTP response — no callback or poll cycle required.
LOM's core agent detects the isolate_url field on your agent record, runs JIT code-gen, issues a single synchronous POST, receives the AOP JSON, and renders it immediately in chat — replacing the traditional "fire-and-forget → wait for callback" pattern with a single edge-optimised HTTP call.
User message → LOM Core Agent → JIT Compiler (Gemini generates query-specific script)
→
POST {isolate_url} body: { "q": query, "code": minified_script }→ Cloudflare Worker pre-fetches place data, runs
run(query, rawData) in V8 sandbox→ Worker returns
{ type, content, metadata } AOP JSON synchronously→ LOM renders AOP map card in chat immediately ✓
Developer Interface — run(query, rawData)
Your transformation script exports a single run(query, rawData) function. The Worker calls it after pre-fetching place data — your function filters, maps, and returns the AOP payload. LOM JIT-compiles a query-specific version of this template on every request, so filtering logic like .filter(p => p.rating > 4.0) is generated dynamically from the user's intent.
The JIT compiler adds .filter() logic automatically when the user's query implies it — e.g. "top rated" generates filter(p => p.rating > 4.0), "Italian spots" generates a cuisine check. Generic queries get a passthrough map with no filter.
AOP Response Format
The Cloudflare Worker returns a flat AOP JSON object synchronously in the HTTP response body. LOM reads it directly — no callback endpoint configuration is needed for Isolate agents. Below is the confirmed live response shape from the v2.18 smoke test.
Connecting Your Isolate Agent to LOM
Isolate-hosted agents are configured self-serve through the Developer Portal. When creating your agent, select Cloudflare Isolate as the connection method and fill in the three fields below. No LOM team involvement required.
| Field | Value | Notes |
|---|---|---|
isolate_url | Your Cloudflare Worker HTTPS endpoint | e.g. https://your-agent.workers.dev. LOM POSTs to this URL on every request — see the Worker Request Contract below for the exact body and header shape your Worker must handle. |
isolate_agent_code | Optional static fallback run(query, rawData) script | Must follow the export default { async run(query, rawData) { ... } } format shown in the Developer Interface above. Used verbatim if Gemini JIT compilation fails. Omit to use LOM's built-in passthrough fallback. |
skill_manifest | JSON string describing your agent's data schema and callable functions | Injected into the JIT compiler's prompt so generated scripts use your exact field names. See the Skill Manifest Format below for the full structure. |
Once set, LOM automatically routes messages to your Isolate endpoint. Existing webhook or gateway URLs on the same agent record are ignored for Isolate agents — the Isolate path takes priority.
Worker Request Contract
LOM sends the following HTTP request to isolate_url on every user message. Your Cloudflare Worker must accept this shape and return the AOP response synchronously in the HTTP response body.
Inbound request from LOM → your Worker:
q— the raw user query string forwarded from chat. When a file is attached, LOM appends a plain-language note toq(e.g.[Attached files: resume.pdf (application/pdf)]) so the LLM inside your Worker understands what was shared without parsingattachmentsdirectly.code— a minifiedrun(query, rawData)script generated by LOM's JIT compiler. Your Worker should execute this in a V8 sandbox and pass the pre-fetched place data asrawData.attachments— array of file objects forwarded from the user's chat message. Each object containsurl,filename,mime_type, andsize_bytes. Theurlis a fully-qualified HTTPS URL — fetch it to read the file bytes. Empty array when no file was shared. See File Receiving →X-LOM-Key— the shared auth token. Your Worker should return401 Unauthorizedif this header is absent or incorrect.user_selfies(lookbook workflows only) — array of absolute HTTPS URLs pointing to the user's saved selfies (up to 3). LOM resolves these from the user's My Selfies collection and injects them automatically when the[workflow:generate_lookbook]prefix is present. Your bridge should fetch these URLs and pass the bytes to your image-generation model as character-reference inputs.reference_images(lookbook workflows only) — array of absolute HTTPS URLs pointing to the user's saved style references (up to 4). LOM resolves these from the user's My Style References collection. Pass them to your model as style/mood-board context alongside the selfies.intent(dispatch schema agents only) — a pre-structured object extracted fromqby LOM before dispatch. Contains exactly the fields you defined in your Dispatch Schema. Omitted entirely when no schema is configured. Read this instead of re-parsingqfor deterministic routing.
Required response from your Worker → LOM:
type— AOP type string. Controls which UI card LOM renders. See the full type reference below →content— human-readable summary string shown above the rendered card.metadata— type-specific data fields. For"map": includeplaces: [{name, lat, lng, category}]. LOM auto-normalisespoints[].label→places[].nameif your Worker returns the older shape.
The response must be returned synchronously — LOM does not poll or wait for a callback from Isolate agents. The timeout is 90 seconds (raised from 60 s to accommodate multi-image lookbook generation). For jobs that take longer (e.g. video processing), use the Async Delivery pattern instead.
AOP Type Reference
Your Worker's type field selects the UI card LOM renders. All metadata fields go at the top level of metadata. Full field tables and copy-able examples for every type are in the deep-dive section immediately below this table.
Domain-specific types
| type | Renders | Minimum required fields |
|---|---|---|
"deal_card" | Price-scan comparison card | query — short product name (3–6 words), shown as card title. See deep-dive for full retailer list schema. |
"map" | Dark interactive pin map + place list | places: [{name, category}] with lat+lng or address per pin. Categories: restaurant | bar | event | activity | happy_hour. |
"itinerary" | Full self-contained travel plan with embedded map (inline) | destination, days: [{day, activities:[{title}]}]. Optionally include hotels[], flights[], tips[]. |
"itinerary_card" | Summary link-out card — navigates to a full report page | report_url OR public_id (required to build the link). Optional: destination, duration_days, travelers, budget_level, day_themes[]. |
"lookbook" | Styled teaser card linking to full lookbook at /lookbook/{public_id} | title, public_id or report_url. Optional: week_of, narrative_excerpt, up to 3 outfit_previews[] thumbnails. |
"image_gallery" | Photo grid (up to 9 images) | images: [{url}]. Optional: title, caption. |
"video_player" | Native video or YouTube/Vimeo embed | video_url (mp4/webm) OR embed_url (YouTube/Vimeo). Also use this type for AI-generated video. |
"social_profile" | Profile card with avatar and links | name. Optional: handle, bio, avatar_url, website, instagram, links[]. |
"product_card" | Single product with image and buy link | name, price. Optional: image_url, brand, retailer, buy_url. |
"generated_image" | AI-generated image with optional caption | image_url (public HTTPS). Optional: prompt (shown as quoted caption). |
"generated_video" | AI-generated video — shares the video_player renderer | video_url (public mp4/webm HTTPS). Optional: poster, title, description, tracks[]. See deep-dive for full field reference. |
"event_card" | Event details with RSVP link | title, date. Optional: time, venue, address, description, price, category, rsvp_url. |
"text" | Plain text — no card | None. content is rendered directly as a text bubble. |
"report" | No standalone card — falls back to text bubble | Use Lego components (list, timeline, stat_grid) for structured reports instead. |
Universal Lego types — work with any agent
| type | Renders | Required metadata fields |
|---|---|---|
"list" | Rich item list | items: [{title, subtitle?, image_url?, url?, tags?, metadata?}] |
"chart" | Bar / line / pie / donut / progress | chart_type, data: [{label, value}]. Optional: unit, colors[]. |
"carousel" | Swipeable image/video slides | slides: [{image_url?, video_url?, video_embed_url?, title?, subtitle?, body?, url?}] |
"stat_grid" | KPI metrics grid (2 or 3 columns) | stats: [{label, value, unit?, trend?, change?, icon?}] |
"timeline" | Chronological events | events: [{title, date?, description?, icon?, url?, media?}] |
"comparison_table" | Side-by-side comparison (2–4 options) | columns: [{name, subtitle?}], rows: [{attribute, values:[]}] |
"table" | Generic scrollable data table | headers: [string], rows: [[value, ...]] |
"audio_player" | Audio player with optional playlist | tracks: [{src, title, artist?, duration?}] or single-track via src + title |
"action_buttons" | CTA button row / 2-column grid | buttons: [{label, url, style?, icon?}] — style: primary | secondary | danger | ghost |
All metadata fields go directly in metadata at the top level of the response object — e.g. {"type":"deal_card","content":"...","metadata":{"query":"...","retailers":[...]}}. If type is omitted, LOM attempts to infer the correct card from the shape of metadata.
Domain-Specific Type Schemas & Examples
▼Complete field reference for every domain-specific AOP type. Each section shows exactly what renders on screen, every supported field, and a full copy-able example payload. Use the Lego component docs above for list, chart, carousel, and other universal types — those are covered separately.
deal_card
Renders a price-comparison card with a highlighted best-price winner and an expandable retailer list. The eyebrow reads "Price Scan · Deals". This is the primary output type for shopping and price-scout agents.
query is the card title. It appears in large type directly below the "Price Scan · Deals" eyebrow. It must be a short, clean product name (3–6 words) such as "Nike Air Max 90" or "Dyson V15 Vacuum". Never pass the raw user message — it will overflow the card. When the user attaches an image, use your vision-identified product name here, not the literal text of their question.
| Field | Type | Required | Renders as |
|---|---|---|---|
query | string | yes | Card title — the product name under the eyebrow. Keep to 3–6 words. |
winner_price | string | number | no | Hero price displayed as $XX (rounded to nearest dollar) |
winner_retailer | string | no | Shown as @ RetailerName beside the hero price |
savings_pct | string | number | no | Badge showing XX% off next to the price |
winner_url | string | no | "View Best Deal →" CTA link |
winner_name | string | no | Full product name shown below the price row (e.g. exact model) |
original_price | string | number | no | Shown as was $XX.XX to communicate discount |
summary | string | no | 1–2 sentence summary paragraph below the winner block |
retailers[] | array | no | Expandable retailer list (up to 5 shown) |
retailers[].name | string | yes* | Retailer display name |
retailers[].price | string | number | yes* | Shown as $XX.XX per row |
retailers[].url | string | no | "Buy →" link on the retailer row |
retailers[].in_stock | boolean | no | Set false to show "Out of stock" label; omit or true for in-stock |
* required within each retailers[] object
map
Renders an interactive dark MapLibre GL map with numbered pins, followed by a scrollable numbered place list. The eyebrow reads "Discovery · Motion". Each place has a popup with rating, hours, price, and Google/Apple Maps links. Tapping the expand button opens the map full-screen. Users can save any place to their collections directly from the card.
| Field | Type | Required | Renders as |
|---|---|---|---|
places[] | array | yes | Ordered place list — up to 15 shown; remainder discarded |
places[].name | string | yes | Place name in the list and map popup |
places[].lat | number | no* | WGS84 latitude — required for a map pin |
places[].lng | number | no* | WGS84 longitude — required for a map pin |
places[].address | string | no* | Street address — auto-geocoded if lat/lng absent. Also shown in popup when description is absent. |
places[].category | string | no | Colour-coded pin label. One of: restaurant (gold) · bar (purple) · happy_hour (teal) · event (blue) · activity (coral). Shown as badge in popup. |
places[].description | string | no | Short description shown in popup (takes precedence over address) |
places[].rating | number | no | Star rating shown as ★ 4.7 in popup and list |
places[].price | string | no | Price label in popup, e.g. "$$" or "from $25" |
places[].hours | string | no | Hours string in popup, e.g. "Mon–Fri 11am–10pm" |
places[].google_maps_url | string | no | Direct Google Maps link used instead of auto-generated one |
places[].booking_url | string | no | "Book →" link shown in the place list row. Falls back to website then url. |
places[].website | string | no | Fallback booking link if booking_url absent |
places[].url | string | no | Final fallback for the "Book →" link if both booking_url and website absent |
title | string | no | Card heading (max 60 chars). Falls back to "Local Discoveries · {location_context}" or "Local Discoveries". |
location_context | string | no | Sub-heading below the title, e.g. "Williamsburg, Brooklyn" |
summary | string | no | Paragraph shown between title and place list |
report_url | string | no | "View Full Report →" link at the bottom of the card |
public_id | string | no | Used to build /map-reports/{public_id} link if report_url absent |
* At least one of lat+lng or address is needed for a map pin. Places without coordinates are shown in the list but not pinned on the map.
itinerary & itinerary_card — two rendering modes
There are two distinct itinerary card types depending on whether you want to render all content inline or link out to a hosted report page:
type: "itinerary_card". Renders a compact, tappable card that navigates the user to a full itinerary report page via report_url or /travel/{public_id}. Only a handful of summary fields are shown on the card face (destination, duration, travelers, budget, day-theme pills). Use this when you are hosting the full itinerary externally or saving it to LOM collections server-side.
| Field | Type | Required | Renders as |
|---|---|---|---|
report_url | string | yes* | Full URL the card links to (your hosted report or LOM report page) |
public_id | string | yes* | LOM-generated ID — card links to /travel/{public_id} if report_url absent |
destination | string | no | Destination headline on the card face |
duration_days | number | no | Shown as 📅 5d |
travelers | number | no | Shown as 👤 2 travelers |
budget_level | string | no | Budget label badge |
day_themes[] | string[] | no | Up to 3 day-theme pills shown on the card (e.g. ["Arrival & Alfama", "Sintra Day Trip"]) |
* Either report_url or public_id is required so the card has a URL to link to.
type: "itinerary". Renders all content directly in the chat: embedded dark map, day-by-day activity list, hotel rows, flight options, and travel tips. Use this when you want everything rendered inline without requiring a hosted report page. No report_url or public_id needed.
Full field reference for type: "itinerary" (inline renderer):
| Field | Type | Required | Renders as |
|---|---|---|---|
destination | string | yes* | Destination label in the hero — e.g. "Lisbon, Portugal". Strips leading "Trip to" if present. |
title | string | yes* | Fallback hero label if destination absent |
duration_days | number | no | Shown as 📅 5 days in the hero row |
travelers | number | no | Shown as 👤 2 travelers in the hero row |
budget_level | string | no | Budget label, e.g. "Mid-range ($100–$200/day)". Generic values like "flexible" are suppressed. |
days[] | array | yes | Day blocks — rendered in order |
days[].day | number | no | Day number shown in day header, e.g. Day 1 |
days[].date | string | no | Shown after the day number as Day 1 · June 14 |
days[].theme | string | no | Day theme shown after a dash, e.g. Day 1 · June 14 — Arrival & Old Town |
days[].activities[] | array | yes | Up to 8 activities per day shown |
activities[].title | string | yes | Activity name — also rendered as a link if booking_url present |
activities[].time | string | no | Time badge, e.g. "9:00 AM" |
activities[].description | string | no | Short description below the title |
activities[].price | string | no | Price badge — "Free" is suppressed; e.g. "€15" |
activities[].category | string | no | Category badge with auto-emoji: food 🍽️ · museum 🏛️ · hike 🥾 · transport 🚌 · hotel 🏨 · shopping 🛍️ · nightlife 🌃 · landmark 📍 and more |
activities[].booking_url | string | no | "Book →" link badge; also makes the title a link. Falls back to url. |
activities[].url | string | no | Fallback link for the activity title and "Book →" badge if booking_url absent |
activities[].google_maps_url | string | no | "Map →" link badge |
activities[].lat / lng | number | no | Places the activity as a numbered pin on the embedded map |
hotels[] | array | no | Hotel rows shown in a "Where to Stay" section; also pinned on map if lat/lng provided |
hotels[].name | string | yes* | Hotel name, optionally followed by ★★★★ stars |
hotels[].stars | number 1–5 | no | Star rating shown as ★ characters beside the name |
hotels[].rating | number | no | Guest review score shown in meta row |
hotels[].price_per_night | number | string | no | Shown as $XX/night badge |
hotels[].neighborhood | string | no | Area label in the meta row |
hotels[].highlights[] | string[] | no | Up to 2 highlight phrases in the meta row |
hotels[].booking_url | string | no | "Book →" link on the hotel name and in meta row |
hotels[].lat / lng | number | no | Pins the hotel on the embedded map as a 🏨 marker |
flights[] | array | no | Flight rows in a "Getting There" section |
flights[].airline | string | no | Carrier name shown in the route headline |
flights[].origin | string | no | Origin code or city, e.g. "JFK" |
flights[].destination_airport | string | no | Destination code or city, e.g. "LIS" |
flights[].price | string | number | no | Price badge, e.g. "$380" |
flights[].departure_time | string | no | Departure time shown in details row |
flights[].arrival_time | string | no | Arrival time (append +1 for next day) |
flights[].duration | string | no | Flight duration, e.g. "7h 55m" |
flights[].stops | number | no | 0 → "Nonstop"; 1 → "1 stop", etc. |
flights[].cabin | string | no | e.g. "Economy", "Business" |
flights[].booking_url | string | no | "Book →" link on the flight row |
tips[] | array | no | Travel tips section at the bottom (up to 4 shown) |
tips[].headline | string | yes* | Tip headline in bold |
tips[].detail | string | no | Tip detail text below the headline |
* destination or title required — at least one must be set. Fields marked yes* are required within their parent array object.
lookbook
Renders a styled teaser card that links out to the full lookbook at /lookbook/{public_id}. The card shows a title, an optional week label, a narrative excerpt (1–2 sentences), and up to 3 outfit preview thumbnails in a row, followed by a "View Full Lookbook →" CTA. The eyebrow reads "Weekly Lookbook · Santi". The full outfit detail lives on the linked page — you do not include inline outfit content in the AOP payload. When public_id is supplied a Share button also copies /lookbook/{public_id}/share to clipboard.
| Field | Type | Required | Renders as |
|---|---|---|---|
title | string | yes | Main card title shown below the eyebrow, e.g. "Your Week 24 Looks" |
public_id | string | yes* | Used to build the destination link /lookbook/{public_id} and the share URL |
report_url | string | yes* | Direct URL — use instead of public_id if hosting the lookbook page yourself |
week_of | string | no | Shown as Week of June 10–16 below the title |
narrative_excerpt | string | no | 1–2 sentence teaser paragraph between the week label and preview thumbnails |
outfit_previews[] | array | no | Up to 3 thumbnail images shown in a row before the CTA. Extras are ignored. |
outfit_previews[].image_url | string | no | Thumbnail image URL (public HTTPS, proxied through LOM CDN) |
outfit_previews[].outfit_title | string | no | Used as the alt attribute for the thumbnail — not displayed as visible text |
* Either public_id or report_url is required so the card has a destination URL to link to.
image_gallery
Renders a responsive photo grid — 3 columns on desktop, 2 on mobile. Up to 9 images are shown; additional images are discarded. The title appears as an eyebrow label above the grid. A global caption can appear below the grid.
| Field | Type | Required | Renders as |
|---|---|---|---|
images[] | array | yes | Ordered image list — up to 9 displayed |
images[].url | string | yes | Image source URL (public HTTPS, proxied through LOM CDN) |
images[].caption | string | no | Used as the alt attribute for accessibility — not displayed as a visible label |
title | string | no | Eyebrow label above the grid; defaults to "Gallery" if absent |
caption | string | no | Overall caption shown below the entire grid |
video_player
Renders either a native HTML5 <video> player (for direct mp4/webm files) or a YouTube/Vimeo <iframe> embed. A title and description can appear below the player. For AI-generated video, use this same type — there is no separate generated_video renderer.
| Field | Type | Required | Renders as |
|---|---|---|---|
video_url | string | yes* | Direct video file (mp4/webm/ogg/mov) — renders as a native <video> element with controls. Takes precedence over embed_url. |
embed_url | string | yes* | YouTube or Vimeo embed URL — rendered as an <iframe>. Used when video_url is absent or not a recognised video extension. |
title | string | no | Video title shown below the player; defaults to "Video" |
description | string | no | Description paragraph below the title |
poster | string | no | Thumbnail shown before the native video plays (has no effect on iframes) |
tracks[] | array | no | Subtitle/caption tracks — only applied to native <video> |
tracks[].src | string | yes* | URL to a WebVTT (.vtt) file |
tracks[].kind | string | no | subtitles (default), captions, descriptions |
tracks[].label | string | no | Label shown in the browser's subtitle menu, e.g. "English" |
tracks[].srclang | string | no | BCP 47 language code, e.g. "en", "es" |
* At least one of video_url or embed_url is required. For AI-generated video, use video_url pointing to your generated mp4 — the renderer is the same as video_player.
YouTube / Vimeo embed variant — use embed_url instead of video_url:
social_profile
Renders a profile card with a circular avatar (falls back to the first letter of name), display name, optional handle, bio, and up to four clickable link chips. Use this for brand profiles, creator pages, contact cards, or any person/entity lookup.
followers is not a rendered field — it was listed in error. Use bio to include follower context, or add a links[] chip pointing to the platform profile. The card does not display a followers count.
| Field | Type | Required | Renders as |
|---|---|---|---|
name | string | yes | Display name in large type |
handle | string | no | Secondary line below the name, e.g. "@username" |
bio | string | no | Bio paragraph below the handle |
avatar_url | string | no | Circular avatar image. Falls back to first letter of name. |
website | string | no | Automatically added as a Website chip in the links row |
instagram | string | no | Automatically added as an Instagram chip in the links row |
links[] | array | no | Custom link chips (up to 4 total including website and instagram) |
links[].label | string | yes* | Chip label text |
links[].url | string | yes* | Link URL (public HTTPS) |
product_card
Renders a single product with a side-by-side image and product details layout. Ideal for surfacing one specific item (a single recommendation, a featured product, or a matched item from a lookup). For comparing multiple products, use comparison_table or a list with product items instead.
description and rating are not rendered — they were listed in error. Only the fields in the table below are displayed by the renderer.
| Field | Type | Required | Renders as |
|---|---|---|---|
name | string | yes | Product name in medium-weight type |
price | string | yes | Price string — include currency symbol, e.g. "$89.95" |
image_url | string | no | Product photo on the left side of the layout (proxied) |
brand | string | no | Brand name shown in small caps above the product name |
retailer | string | no | Shown as at Retailer in a muted label beside the price |
buy_url | string | no | "View Product" CTA button. Falls back to url if absent. |
url | string | no | Fallback product link if buy_url absent |
generated_image
Renders a full-width AI-generated image with an optional quoted prompt caption below it. Use this when your agent generates an image via an image-generation API and wants to display it inline in the conversation. The image URL must be a public HTTPS URL — the platform does not accept base64 data URIs or unsigned CDN URLs.
| Field | Type | Required | Renders as |
|---|---|---|---|
image_url | string | yes | Full-width image. Must be a public HTTPS URL — no data URIs. |
prompt | string | no | Shown as a quoted caption in italic below the image, e.g. "a sun-drenched terrace overlooking the sea". Also used as the image alt attribute. |
generated_video
generated_video shares the same renderer as video_player — there is no separate card component. Pass your generated video URL via video_url (pointing to a public mp4/webm file) and the platform renders a native HTML5 video player with controls. The poster field is especially useful here since there is no platform-generated thumbnail — set it to a frame from your generated video to avoid a blank player before play.
| Field | Type | Required | Renders as |
|---|---|---|---|
video_url | string | yes | Direct URL to the generated mp4 or webm file (public HTTPS). Renders as a native <video> player with controls. |
poster | string | no | Thumbnail image shown before the video plays. Highly recommended — the player shows a blank frame without it. |
title | string | no | Video title shown below the player |
description | string | no | Description paragraph below the title |
tracks[] | array | no | Subtitle/caption tracks — see the video_player section above for the full tracks[] schema |
For long generation jobs that exceed the 90-second Isolate timeout, generate the video asynchronously on your bridge and return the URL via the Async Delivery pattern when ready.
event_card
Renders an event details card showing the event name, date/time, venue, description, and an "RSVP / Tickets →" link. The eyebrow reads "Event · Motion". price and category appear as pill badges. Users can save the event to their collections directly from the card.
image_url is not rendered — it was listed in error. The card is text-only. For an event with a hero image, use a carousel with one slide followed by an event_card in a components[] envelope.
| Field | Type | Required | Renders as |
|---|---|---|---|
title | string | yes | Event name — large type in card header |
date | string | yes | Date string shown below the title, e.g. "Saturday, June 15" |
time | string | no | Appended to date with a · separator, e.g. "7:00 PM" |
venue | string | no | Venue name shown in the body. Falls back to address. |
address | string | no | Street address shown if venue absent |
description | string | no | Event description paragraph |
price | string | no | Price pill, e.g. "$35–$75" or "Free" |
category | string | no | Category pill, e.g. "Music", "Art", "Comedy" |
rsvp_url | string | no | "RSVP / Tickets →" CTA link. Falls back to booking_url then website. |
booking_url | string | no | Fallback RSVP link if rsvp_url absent |
website | string | no | Fallback link if neither rsvp_url nor booking_url present |
lat / lng | number | no | Not displayed — used for save-to-collection geo tagging only |
report — no standalone card
The report type does not have its own card renderer. When returned, the content string is displayed as a plain text bubble — there is no structured card UI. The title, summary, and sections[] fields documented in earlier versions of the docs are not rendered by the platform frontend.
| Field | Type | Required | Renders as |
|---|---|---|---|
content | string | yes | The only field that is rendered — displayed as a plain text bubble. Supports line breaks (\n). Use markdown-style bold (**text**) for emphasis if your content pipeline supports it. |
download_url | string | no | If provided, a "Download Report" button is shown below the text bubble linking to the file (PDF, HTML, CSV, etc.) |
title | string | — | NOT rendered — included here for documentation only. Was listed in older versions of the spec but has no effect. |
summary | string | — | NOT rendered — see title note above. |
sections[] | array | — | NOT rendered — use Lego components (list, table, stat_grid, timeline) for structured sections instead. |
For structured reports, compose Lego components instead:
- Use
stat_gridfor key metrics at the top. - Use
chartfor trends over time. - Use
tableorlistfor the body of the report. - Use a
timelinefor chronological findings. - Use
action_buttonsat the end for CTAs. - Wrap them all in a
components[]envelope to send multiple cards in a single response.
If your agent generates a full report document (PDF, HTML), return it via download_url (universal across all card types — see the File Delivery section) and use content for the summary text.
Example — how type: "report" actually renders (content shown as a text bubble; metadata fields are silently ignored):
The metadata fields title, summary, and sections are not rendered — only content (as a text bubble) and download_url (as a download button) are used. Use Lego components for structured output instead.
Skill Manifest Format
The skill_manifest field is a JSON string stored on your agent record. LOM injects it verbatim into the Gemini JIT prompt so the generated run(query, rawData) script uses your exact field names, available functions, and data shape — making the generated code correct without any manual script authoring.
available_functions— list the callable skills your Worker exposes. Each entry needsname,args(array of argument names), anddescription. The JIT compiler uses this to know what data the Worker can fetch.data_schema— describes the shape of each object inrawData. Use real field names from your data source. The JIT compiler reads this to generate correctp.location.latstyle accessors rather than guessing field paths.- The manifest is stored as a JSON string in the database column. Paste the stringified JSON directly into the Skill Manifest field in the Developer Portal when creating your agent.
- Agents without a manifest still work — the JIT compiler falls back to a generic prompt that assumes the standard MotionBlur schema.
OpenClaw + Isolate
The Isolate architecture is the next evolution of OpenClaw agents. If you already run an OpenClaw Gateway, you can migrate your agent to the Isolate path incrementally: deploy your agent code as a Cloudflare Dynamic Worker, set isolate_url on your LOM agent record, and LOM will automatically use the Isolate path while your Gateway remains available for other channels (Telegram, WhatsApp, etc.).
Async Delivery
The Isolate timeout is 60 seconds — enough for most agents. But some jobs take longer: generating four outfit images in parallel (the stylist-v1 agent takes ~45 s), processing a video, or running a multi-step research pipeline. For these, the Async Delivery pattern lets your agent return immediately with placeholder content, then let the frontend poll your own endpoint for results as they arrive.
The platform does not own the job state or poll endpoint — your agent bridge does. The platform's frontend polls the URL your agent specifies, replacing placeholders with real content as each item resolves.
How it works
- Your agent starts the long-running job on its bridge (image gen, video edit, etc.) and immediately returns an AOP response where items carry
image_status: "pending". - LOM renders the card immediately with shimmer placeholders for each pending item.
- The frontend polls each item's
job_poll_urldirectly every 5 seconds — no parameters are appended. The job identity must be encoded in the URL itself (path segment or query string you set when returning the initial AOP response). - As items complete on your bridge, your poll endpoint returns updated data. LOM swaps each placeholder for the real content in-place — no page reload.
- Polling stops when all items are resolved, or after 3 minutes (graceful degradation — placeholders remain with a retry hint).
Initial AOP response — pending items
Return this immediately from your Isolate Worker. Each item that isn't ready yet carries image_status: "pending", a job_id, and a unique job_poll_url that points to just that item's status. The platform renders a shimmer placeholder for each pending item.
Important: each item's job_poll_url must be unique — the platform calls it directly with no added parameters. Encode all the context needed to return that one item's status into the URL itself (e.g. as a path segment or query string you set).
image_status— set to"pending"on items not yet ready. The platform renders a shimmer placeholder. Omit or set to anything other than"pending"for items that are already resolved at response time.job_id— an opaque identifier your bridge uses to look up the job internally.job_poll_url— a unique per-item HTTPS URL on your bridge. The platform fetches this URL as-is (no query params appended). Must be CORS-accessible from the browser (Access-Control-Allow-Origin: *).
Poll endpoint — what your bridge must return
The platform calls each item's job_poll_url directly via GET. Your endpoint returns the status of that single item. When status is "complete" or "completed", the platform swaps the shimmer for the real image using image_url. When status is "failed" or "error", the shimmer is removed gracefully.
status—"complete"or"completed"triggers the swap."failed"or"error"removes the shimmer. Any other value keeps polling.image_url— the final image URL. Also accepted:generated_image_url.
The poll endpoint is owned entirely by your bridge — the platform never proxies it. Design it to return quickly (it is called every 5 s). A simple in-memory map or Redis hash keyed by job + item index is sufficient state storage.
Which agents benefit from this pattern?
| Agent type | Typical job duration | Recommended approach |
|---|---|---|
| Text / search / map | < 5 s | Synchronous — return directly from Worker |
| Multi-image lookbook (4 images, personalised) | 45–60 s | Synchronous within 90 s budget — LOM auto-attaches user_selfies + reference_images to the Worker body |
| Video edit (short clip) | 1–3 min | Async — see Video Agent Walkthrough |
| Deep research / report | 30–90 s | Async if > 60 s; sync otherwise |
| Audio generation | 15–45 s | Async — return pending, poll bridge |
File Receiving
Users can attach files directly in the LOM chat interface — images, PDFs, documents, and more. When a file is present, LOM forwards it to your agent in every connection type (Isolate Worker and webhook) via an attachments array. Your agent receives the file as a URL it can fetch, parse, and act on — enabling fully agentic workflows like resume analysis, document summarisation, image processing, and data extraction.
Supported file types
| Category | Accepted MIME types |
|---|---|
| Images | image/jpeg, image/png, image/webp, image/gif |
| Video | video/mp4, video/quicktime, video/webm |
| Documents | application/pdf, text/plain, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document |
The attachments field
LOM injects attachments into both the webhook POST body and the Isolate Worker request body. Each item in the array is an object with these fields:
| Field | Type | Description |
|---|---|---|
url | string | Fully-qualified HTTPS URL to the uploaded file. Fetch this to read the bytes. |
filename | string | Original filename as uploaded by the user (e.g. resume.pdf). |
mime_type | string | MIME type detected at upload time (e.g. application/pdf). |
size_bytes | integer | File size in bytes. |
Additionally, LOM appends a plain-language note to the query string q when a file is present — e.g. [Attached files: resume.pdf (application/pdf)]. This means the LLM inside your agent already knows about the file from q alone; reading attachments and fetching the URL is only needed when you want to process the file's actual contents.
Isolate Worker — reading a file
Inside your Cloudflare Worker, read body.attachments and fetch the URL to retrieve the file bytes. The example below shows a resume-analysis agent:
Webhook agent — reading a file
For webhook agents, attachments is a top-level field in the inbound POST body — same shape as above. Read it the same way in any language:
Declaring file support in your workflow manifest
If your agent is built around file uploads, set has_file_input: true in your workflow manifest. LOM's intent classifier uses this to route file-bearing messages to your agent rather than treating the attachment as a generic input.
File Delivery
Any AOP card type can include a download_url field in its metadata. When present, the platform renders a download button alongside the card content — letting the user save the file your agent produced. This is the mechanism that lets agents deliver tangible artifacts to users: edited videos, generated PDFs, audio tracks, spreadsheets, or any other file your bridge can host.
download_url — the universal delivery field
download_url is a top-level field in your AOP metadata object. Set it to any publicly accessible HTTPS URL pointing to the file you want to deliver. The platform renders a Download button in the card footer. The browser handles the download natively — no platform-side storage or proxying required.
In this example, video_url streams a compressed preview directly in the video player, while download_url links to the full-resolution output file. The user can watch the preview and then tap Download to save the final cut. download_label overrides the default button text; download_filename suggests a filename to the browser when the user saves the file.
Works with any card type
download_url is not exclusive to video_player. Any agent that produces a file can include it in any card type's metadata.
| Agent | AOP card type | download_url points to |
|---|---|---|
| Video editor | video_player | Final cut MP4 / MOV on your bridge |
| PDF generator | report | Generated PDF hosted on your bridge |
| Audio producer | audio_player | Mastered WAV / MP3 on your bridge |
| Spreadsheet agent | table | XLSX / CSV export on your bridge |
| Image generator | generated_image | Full-resolution PNG / JPG on your bridge |
| Research agent | report | PDF report compiled on your bridge |
Video Editing Agent — End-to-End Walkthrough
This walkthrough shows how to build an agent that accepts a raw video URL, processes it on a VPS bridge (cut, colour-grade, add music), and delivers the result directly in chat with a download button — all using the Isolate + Async + File Delivery patterns together. Processing a short clip typically takes 1–3 minutes, well beyond the 60-second synchronous limit, so the Async Delivery pattern is required.
User sends a video URL in chat
The user pastes a raw video URL and describes what they want:
LOM routes the message to your Isolate Worker via POST {isolate_url} with q and code in the body, authenticated with your X-LOM-Key header.
Worker starts the job and returns immediately
Your Cloudflare Worker parses the query, extracts the video URL, POSTs a job to your VPS bridge (which accepts it and queues it), then immediately returns an AOP response with image_status: "pending":
LOM renders a pending card — frontend polls your bridge
LOM renders the video card immediately with a shimmer placeholder where the video will appear. The frontend polls the job_poll_url value directly every 5 seconds — no parameters are appended.
Your bridge poll endpoint returns the current job state. While still processing:
When the edit finishes, your bridge updates its job record and the next poll returns the completed state. Use "status": "complete" (or "completed") — the platform recognises both:
Platform swaps in the video — user sees the result
On the next successful poll, LOM replaces the shimmer placeholder with the native video player (using video_url for inline streaming) and renders a Download button (from download_url) in the card footer. The user can watch the preview in chat and tap Download to save the full-resolution file.
What your VPS bridge needs
- Job queue endpoint —
POST /edit— acceptsvideo_urlandparams; starts async processing; returnsjob_idimmediately. - Poll endpoint —
GET /jobs/{job_id}/status— returns{"status": "pending"|"complete"|"failed", "video_url", "download_url"}. Must respond quickly — called every 5 s by the browser. Enable CORS. - File hosting — serve processed video files over HTTPS. Cloudflare R2, S3, or a simple NGINX directory all work. The platform links directly — it never stores or proxies your files.
Platform Architecture / Trusted Workers
Redis is an internal transport. External agents interact only through event-driven HTTP. This section documents how LOM's own first-party infrastructure works internally. Nothing here is available to external developers — it is provided for transparency and for LOM engineers building trusted workers.
Trust Boundary
The platform enforces a hard boundary between trusted and untrusted callers:
| Caller type | Transport | Notes |
|---|---|---|
| Trusted workers — LOM infra, first-party code | Redis Streams (internal only) | ACL-isolated per worker; never exposed beyond the service boundary |
| Untrusted agents — third-party, external, user-owned | Event-driven HTTP only (webhooks, A2A) | No Redis access; authenticated via API key |
Redis is never exposed beyond the service boundary. External developers cannot and should not attempt to connect to the Redis cluster directly — there is no credential provisioning path for external agents, by design.
Redis Streams — Internal Infrastructure
LOM's internal message bus uses Redis Streams with consumer groups to fan messages from the chat layer to first-party agent workers. Each worker is assigned a scoped ACL that restricts it to exactly its own stream key.
Consumer group pattern (internal workers only):
ACL isolation principles:
-@all— deny all commands by default- Only the six stream commands needed are explicitly allowed:
XREADGROUP,XACK,XAUTOCLAIM,XGROUP,XREAD - Key pattern
~stream:agent:{worker_type}restricts access to exactly one stream per worker — cross-worker reads are impossible - Consumer group name
workersis shared within a worker type, enabling horizontal scaling; Redis consumer groups provide at-least-once delivery — unacknowledged messages re-appear viaXAUTOCLAIM
Worker lifecycle (internal reference):
| # | Step |
|---|---|
| 1 | Connect to Redis and join the consumer group (XGROUP CREATE … MKSTREAM) |
| 2 | Claim messages: XREADGROUP GROUP workers {consumer} COUNT 1 BLOCK 5000 STREAMS stream:agent:{type} > |
| 3 | Read message_id from the stream entry ID |
| 4 | Load fresh session context from Postgres (stateless by design — no in-memory state between turns) |
| 5 | Call the LLM / process the request |
| 6 | POST response to /chat/callback with message_id for idempotency |
| 7 | Acknowledge: XACK stream:agent:{type} workers {entry-id} |
Ordering note: Consumer groups process in parallel — a later message may complete before an earlier one. The stateless Postgres-load design mitigates this because each turn reloads fresh context. If strict per-session ordering is required in future, per-session stream partitioning is the fix.
Per-agent Redis credential provisioning is not built yet and is not on the external developer roadmap. If you have a use-case that requires tighter integration, contact the LOM team.
⚠️ Common Pitfalls
- Always echo
session_id— the callback must include the exactsession_idfrom the incoming payload. Omitting it or generating a new one causes the reply to fail silently. - Webhook: respond HTTP 200 within 15 s — LOM waits up to 15 seconds for acknowledgement before marking the delivery as failed and retrying. Do not block on LLM processing in the request handler; acknowledge first, then call back asynchronously.
- Webhook: verify your Bearer token — always check the
Authorizationheader matches your configuredwebhook_tokenbefore processing any payload. - Set a real
User-Agentheader — Cloudflare may block default library user agents (Pythonurllib,curl/x.x). Use a descriptive string likeMyAgent/1.0.
Machine-Readable Spec
openapi: "3.0.3"
info:
title: LifeOfMine Agent API
version: "1.0"
servers:
- url: https://lifeofmine.ai
paths:
/chat/callback:
post:
summary: Send a reply back to the user
operationId: sendCallback
security:
- channelSecret: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- session_id
- content
properties:
session_id:
type: string
description: Session ID received in the poll payload.
content:
type: string
description: The reply text (or structured content) to deliver to the user.
type:
type: string
default: text
description: Message type — "text" for plain replies.
final:
type: boolean
description: If false, treated as a live status/streaming update rather than a final reply.
tokens_used:
type: integer
description: Optional token count for the response (used for usage tracking).
responses:
"200":
description: Reply accepted.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
"403":
description: Invalid or missing X-Channel-Secret header.
components:
securitySchemes:
channelSecret:
type: apiKey
in: header
name: X-Channel-Secret