Skip to content

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:

  1. Setting lifecycle.<hook>: true in the manifest
  2. Registering a handler with the SDK

The SDK refuses to register a handler whose corresponding lifecycle flag is false — fast feedback when you forget.

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).

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.

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.

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}`)
})

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.

HookDeliveryRetry
on_installAt-least-onceYes, on transient failure
on_uninstallAt-least-onceYes
on_inboundBest-effortNo (the channel itself owns retry)
on_user_addedAt-least-onceYes
on_scheduleAt-most-onceNo (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.

  • 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.