Activities

Temporal activities execute non-deterministic operations. Register a function.lua or process.lua entry as an activity through its metadata.

The snippets are API recipes. The payment example is illustrative and requires an application-owned environment entry, env.get permission for the credential, http_client.request permission for the provider URL, and a payment-provider contract.

Registering Activities

Add meta.temporal.activity to register a function as an activity:

- name: charge_payment
  kind: function.lua
  source: file://payment.lua
  method: charge
  modules:
    - env
    - errors
    - http_client
    - json
  meta:
    temporal:
      activity:
        worker: app:worker

Metadata Fields

Field Required Description
worker Yes Reference to temporal.worker entry
local No Execute as local activity (default: false)
name No Custom activity type name (defaults to entry ID)

Implementation

Activities are regular Lua functions. Keep credentials out of workflow inputs because Temporal persists those inputs in workflow history. This example reads the payment key from the environment registry inside the activity. Its placeholder provider accepts a JSON charge request and returns a JSON response. The status mapping is an application-owned policy: replace the URL, request fields, response fields, and failure mapping with your provider's contract.

-- payment.lua
local http = require("http_client")
local json = require("json")
local env = require("env")
local errors = require("errors")

local function payment_error(status)
    if status == 408 then
        return errors.new({kind = errors.TIMEOUT, message = "payment provider timed out", retryable = true})
    elseif status == 429 then
        return errors.new({kind = errors.RATE_LIMITED, message = "payment provider rate limited the request", retryable = true})
    elseif status >= 500 then
        return errors.new({kind = errors.UNAVAILABLE, message = "payment provider is unavailable", retryable = true})
    end
    return errors.new({kind = errors.INVALID, message = "payment request was rejected", retryable = false})
end

local function charge(input)
    local api_key, env_err = env.get("PAYMENTS_API_KEY")
    if env_err then return nil, env_err end

    local body, encode_err = json.encode({
        amount = input.amount,
        currency = input.currency,
        payment_token = input.payment_token
    })
    if encode_err then
        return nil, encode_err
    end

    local response, err = http.post("https://payments.example.com/v1/charges", {
        headers = {
            ["Authorization"] = "Bearer " .. api_key,
            ["Content-Type"] = "application/json"
        },
        body = body
    })

    if err then
        return nil, err
    end

    if response.status_code >= 400 then
        return nil, payment_error(response.status_code)
    end

    return json.decode(response.body)
end

return { charge = charge }

Calling Activities

From workflows, use the funcs module:

local funcs = require("funcs")

local result, err = funcs.call("app:charge_payment", {
    amount = 5000,
    currency = "usd",
    payment_token = "payment-token-123"
})

if err then
    return nil, err
end

Activity Options

Configure timeouts, retry behavior, and other execution parameters using the executor builder:

local funcs = require("funcs")

local executor = funcs.new():with_options({
    ["activity.start_to_close_timeout"] = "30s",
    ["activity.schedule_to_close_timeout"] = "5m",
    ["activity.heartbeat_timeout"] = "10s",
    ["activity.retry_policy"] = {
        maximum_attempts = 3,
        initial_interval = 1000,
        backoff_coefficient = 2.0,
        maximum_interval = 60000,
    }
})

local result, err = executor:call("app:charge_payment", input)

The executor is immutable and reusable. Build it once and use it for multiple calls:

local reliable = funcs.new():with_options({
    ["activity.start_to_close_timeout"] = "60s",
    ["activity.retry_policy"] = {
        maximum_attempts = 5,
        initial_interval = 2000,
        backoff_coefficient = 2.0,
        maximum_interval = 120000,
    }
})

local a, err = reliable:call("app:step_one", input)
if err then
    return nil, err
end
local b, err = reliable:call("app:step_two", a)
if err then
    return nil, err
end

Options Reference

Option Type Default Description
activity.start_to_close_timeout duration 10m Max time for activity execution
activity.schedule_to_close_timeout duration - Max time from scheduling to completion
activity.schedule_to_start_timeout duration - Max time before activity starts
activity.heartbeat_timeout duration - Max time between heartbeats
activity.id string - Custom activity execution ID
activity.task_queue string - Override task queue for this call
activity.wait_for_cancellation boolean false Wait for activity cancellation
activity.disable_eager_execution boolean false Disable eager execution
activity.retry_policy table - Retry configuration (see below)
activity.name string - Activity type name to call, when it differs from the registry ID
activity.summary string - Human-readable summary shown in the Temporal UI
activity.priority table - Task priority: priority_key (number), fairness_key (string), fairness_weight (number)
activity.versioning_intent string - compatible (inherit the build ID) or default (use assignment rules)

Duration values accept strings ("5s", "10m", "1h") or milliseconds as numbers.

Use the canonical activity.* names for new code. Legacy temporal.activity.* aliases remain accepted for compatibility.

local executor = funcs.new():with_options({
    ["activity.summary"] = "Charge the order payment",
    ["activity.priority"] = {
        priority_key = 10,
        fairness_key = "customer-123",
        fairness_weight = 1.0,
    },
    ["activity.name"] = "charge-payment",
    ["activity.versioning_intent"] = "use_assignment_rules",
})

Retry Policy

Configure automatic retry behavior for failed activities:

["activity.retry_policy"] = {
    initial_interval = 1000,         -- ms before first retry
    backoff_coefficient = 2.0,       -- multiplier for each retry
    maximum_interval = 300000,       -- max interval between retries (ms)
    maximum_attempts = 10,           -- max retry attempts (0 = unlimited)
    non_retryable_error_types = {    -- errors that skip retries
        "Invalid",
        "PermissionDenied"
    }
}
Field Type Default Description
initial_interval number 1000 Milliseconds before first retry
backoff_coefficient number 2.0 Multiplier applied to interval each retry
maximum_interval number - Cap on retry interval (ms)
maximum_attempts number 0 Max attempts (0 = unlimited)
non_retryable_error_types array - Error kinds that bypass retries

Timeout Relationships

|--- schedule_to_close_timeout --------------------------------|
|--- schedule_to_start_timeout ---|--- start_to_close_timeout -|
     (waiting in queue)                (executing)
  • start_to_close_timeout: How long the activity itself can run. This is the most commonly used timeout.
  • schedule_to_close_timeout: Total time from when the activity is scheduled until it completes, including queue wait time and retries.
  • schedule_to_start_timeout: Max time the activity can wait in the task queue before a worker picks it up.
  • heartbeat_timeout: For long-running activities, the max time between heartbeat reports.

Local Activities

The local field is accepted on an activity:

- name: validate_input
  kind: function.lua
  source: file://validate.lua
  method: validate
  modules:
    - json
  meta:
    temporal:
      activity:
        worker: app:worker
        local: true

Currently local: true is parsed but behaves identically to a regular activity: it is registered and executed through the standard activity path. There is no distinct local-activity execution yet, so it does not change latency, task queue behavior, or heartbeating.

Activity Naming

Activities are registered with their full entry ID as the name:

namespace: app
entries:
  - name: charge_payment
    kind: function.lua
    # ...

Activity name: app:charge_payment

Context Propagation

Context values set when spawning the workflow are available inside activities:

-- Spawner sets context
local spawner = process.with_context({
    user_id = "user-1",
    tenant = "tenant-1",
})
local pid, err = spawner:spawn("app:order_workflow", "app:worker", order)
if err then
    return nil, err
end
-- Activity reads context
local ctx = require("ctx")

local function process_order(input)
    local user_id, user_err = ctx.get("user_id")   -- "user-1"
    if user_err then return nil, user_err end
    local tenant, tenant_err = ctx.get("tenant")   -- "tenant-1"
    if tenant_err then return nil, tenant_err end
    -- use context for authorization, logging, etc.
end

Activities called from a workflow with funcs.new():with_context() also propagate context:

-- Inside workflow
local executor = funcs.new():with_context({trace_id = "abc-123"})
local result, err = executor:call("app:charge_payment", input)

Security Context

An activity scheduled under a security context receives the signed wippy-security header, audienced to the activity ID. The worker verifies the signature and audience, then merges the propagated ctx values and the security payload onto a fresh frame before the activity function runs.

That merge is all-or-nothing and fatal to the activity if it fails: the activity returns an error before its code executes, so it never runs with partial context or with an unverified actor. A merge fails when the signature or audience does not verify, when the envelope is inconsistent (an actor without a scope, or policies without an actor), or when a policy named in the envelope does not resolve in the local security registry — which is the common operational cause: the worker's deployment is missing a policy entry the caller had.

The worker takes its signing and verification keys from the temporal.client entry it references. See Security context propagation.

Error Handling

Return errors via the standard Lua pattern:

local errors = require("errors")

-- Replace this mapping with the payment provider's documented error contract.
local function payment_error(status)
    if status == 408 then
        return errors.new({kind = errors.TIMEOUT, message = "payment provider timed out", retryable = true})
    elseif status == 429 then
        return errors.new({kind = errors.RATE_LIMITED, message = "payment provider rate limited the request", retryable = true})
    elseif status >= 500 then
        return errors.new({kind = errors.UNAVAILABLE, message = "payment provider is unavailable", retryable = true})
    end
    return errors.new({kind = errors.INVALID, message = "payment request was rejected", retryable = false})
end

local function charge(input)
    if not input.amount or input.amount <= 0 then
        return nil, errors.new({ kind = errors.INVALID, message = "amount must be positive" })
    end

    local response, err = http.post(url, options)
    if err then
        return nil, errors.wrap(err, "payment API failed")
    end

    if response.status_code >= 400 then
        return nil, payment_error(response.status_code)
    end

    return json.decode(response.body)
end

Error Objects

Activity errors propagated to workflows carry structured metadata:

local result, err = funcs.call("app:charge_payment", input)
if err then
    err:kind()       -- error classification string
    err:retryable()  -- boolean, whether retry makes sense
    err:message()    -- human-readable error message
end

Failure Modes

Failure Error Kind Retryable Description
Application error Whatever the activity returned Inherited from the returned error Error returned by activity code via return nil, err
Runtime crash Internal false Unhandled Lua error in activity
Missing activity NotFound false Activity not registered with worker
Timeout Timeout false Activity exceeded configured timeout
Security verification Internal true Signature, audience, or envelope check failed on the propagated security header
Security policy missing Internal true A policy named in the security envelope does not resolve on this worker

Both security failures happen during context merge, before the activity function runs. They are not marked non-retryable, so the activity retry policy keeps re-attempting them; retries do not help, because neither a bad signature nor a missing policy entry changes between attempts. Cap maximum_attempts on activities you want to fail fast, and read a repeating Internal failure with no activity log output as a context-merge failure rather than a fault in the activity.

local executor = funcs.new():with_options({
    ["activity.retry_policy"] = {maximum_attempts = 1}
})

local result, err = executor:call("app:missing_activity", input)
if err then
    print(err:kind())      -- "NotFound"
    print(err:retryable())  -- false
end

Process Activities

process.lua entries can also be registered as activities for long-running operations:

- name: long_task
  kind: process.lua
  source: file://long_task.lua
  method: main
  modules:
    - http_client
  meta:
    temporal:
      activity:
        worker: app:worker

See Also