Lifecycle hooks
The platform fires lifecycle events at well-known moments in a tenant’s relationship with your app. Subscribe to the ones you need by:
- Setting
lifecycle.<hook>: truein the manifest - Registering a handler with the SDK
The SDK refuses to register a handler whose corresponding lifecycle
flag is false — fast feedback when you forget.
on_install
Section titled “on_install”Fires once when a tenant activates your app for the first time.
Use for: provisioning per-tenant resources, registering webhooks on the partner side, validating that every required integration the tenant claimed they have actually works.
app.onInstall(async (ctx) => { console.log(`[my-app] installed for tenant=${ctx.tenantId}`) // Sanity-check: can we actually talk to Odoo? await ctx.tools.call('odoo_search_read', { model: 'res.users', limit: 1 })})Idempotency: the platform retries on transient failure. Make this handler safe to run twice (e.g. if you create a record, check first or use upsert semantics).
on_uninstall
Section titled “on_uninstall”Fires when a tenant deactivates your app.
Use for: tearing down partner-side resources you provisioned in
on_install. Don’t delete tenant data here — the platform doesn’t
guarantee at-least-once delivery, and “delete on uninstall” is rarely
the right semantic anyway.
on_inbound
Section titled “on_inbound”Fires for every inbound message a tenant routes to your app. The tenant has to opt in to fan-out for your app — without that opt-in, the platform routes the message to its standard agent and your app never sees it.
Use for: classification, automated replies, ticketing, triage.
app.onInbound(async (ctx, env) => { if (env.channel !== 'email') return // ignore non-email inbounds const category = classify(env.body) await ctx.tools.call('email_send', { to: env.from, subject: `Re: ${env.subject ?? ''}`, body: `Filed under '${category}'.`, })})The env payload (InboundEnvelope) carries the message text,
attachments, and channel metadata. Reply via ctx.tools.call; do not
respond by returning data — the platform doesn’t read the handler’s
return value.
on_user_added
Section titled “on_user_added”Fires when a new user joins a tenant that has your app installed.
Use for: provisioning per-user state, sending a welcome message, bootstrapping a sub-tenant scope your app maintains.
app.onUserAdded(async (ctx, user) => { console.log(`[my-app] new user ${user.id} on tenant ${ctx.tenantId}`)})on_schedule:<name>
Section titled “on_schedule:<name>”Cron-fired handler. Declared in the manifest:
lifecycle: schedules: - name: daily-summary cron: "0 8 * * *" - name: hourly-poll cron: "0 * * * *"app.onSchedule('daily-summary', async (ctx) => { ... })app.onSchedule('hourly-poll', async (ctx) => { ... })The platform’s leader-elected scanner ticks every 60s and matches crons with minute precision. At-most-once delivery — if a worker restart spans the tick window, that minute’s run is skipped.
Delivery semantics
Section titled “Delivery semantics”| Hook | Delivery | Retry |
|---|---|---|
on_install | At-least-once | Yes, on transient failure |
on_uninstall | At-least-once | Yes |
on_inbound | Best-effort | No (the channel itself owns retry) |
on_user_added | At-least-once | Yes |
on_schedule | At-most-once | No (next tick comes regardless) |
Write your handlers to be safe under each model. Idempotency keys are a developer’s friend — store the message_id / user_id / tick-time in your own state to dedup.
Errors and timeouts
Section titled “Errors and timeouts”- Handlers have a 30-second soft timeout for tool calls and a 60-second wall-clock cap before the platform aborts the container’s response.
- An exception in a handler:
- Is logged to the container’s stderr (visible in the Logs tab)
- Returns
{ok: false, error: "..."}to the platform - Does NOT crash the container
- The error message is visible in the Logs tab but stack traces stay out of the HTTP response — the trust boundary between partner code and platform.