Skip to content

Identity

Every workflow runs with identity. An activity knows who started the work, whose permissions govern it and which credentials it may use, and that context travels through the durable engine on its own. This page covers the identity model, service accounts, credential resolution, and the scope that decides what each member sees and does in a queue.

Dimension Meaning Stored as
Initiator The person or schedule that started the workflow. Never changes. initiated_by (a user id)
Principal The identity the workflow runs as: the initiator, or a service account via execute_as. Decides RBAC and credentials. principal_type (user or bot), executing_as
Credentials OAuth tokens and API keys available to the principal, resolved at call time lt_oauth_tokens

Read all three from any activity:

activities/fetch-data.ts
import { getActivityIdentity } from '@hotmeshio/long-tail';
export async function fetchData(input: { query: string }) {
const identity = getActivityIdentity();
if (!identity) throw new Error('getActivityIdentity runs inside an activity');
identity.principal.id; // who is executing
identity.principal.type; // 'user' | 'bot'
identity.principal.roles; // ['engineer', 'admin']
identity.initiatingPrincipal; // who asked, when execute_as is in play
const token = await identity.getCredential('anthropic');
}

Identity flows from the request to the activity without any code of yours:

HTTP request (JWT or lt_bot_ key)
→ req.auth.userId
→ envelope lt.userId / lt.executeAs
→ interceptor resolves roles and scopes from the database
→ activity interceptor injects the principal
→ getActivityIdentity() / getToolContext()

A service account is a row in lt_users with account_type = 'bot'. It has its own roles, scopes and stored credentials, and behaves like a person in every functional respect. account_type exists for audit, not authorization.

  1. Create the account (builder access: superadmin or the engineer role).

    Terminal window
    curl -X POST https://lt.acme.dev/api/bot-accounts \
    -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \
    -d '{ "name": "data-bot", "description": "Nightly analytics" }'
  2. Give it roles.

    Terminal window
    curl -X POST https://lt.acme.dev/api/bot-accounts/$BOT_ID/roles \
    -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \
    -d '{ "role": "reviewer", "type": "member" }'
  3. Generate a key. The raw key is returned once.

    Terminal window
    curl -X POST https://lt.acme.dev/api/bot-accounts/$BOT_ID/api-keys \
    -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \
    -d '{ "name": "production", "scopes": ["mcp:read"] }'
    # { "id": "...", "rawKey": "lt_bot_a1b2c3..." }
  4. Call the API with it.

    Terminal window
    curl -H "Authorization: Bearer lt_bot_a1b2c3..." https://lt.acme.dev/api/escalations

Keys start with lt_bot_, are bcrypt-hashed at rest, and pass through the same auth middleware as a person’s token. scopes decides what the key sees at /mcp: ["mcp:read"] for read-safe tools, ["mcp:read", "mcp:full"] for everything the account’s roles allow. Keys can carry expires_at. Revoke one with DELETE /api/bot-accounts/:id/api-keys/:keyId.

The same is available in the dashboard under Accounts > Service Accounts and as the MCP tools create_bot_account and create_bot_api_key.

Use execute_as when a workflow needs permissions or credentials that differ from the caller’s: a nightly job that runs as data-bot with a read-only database credential, or a CI pipeline that invokes as ci-bot.

Where Effect
Workflow config (executeAs on the worker’s config) Every invocation runs as that account
Per request (execute_as in the invoke body) This run only

The override never exceeds the caller’s own authority. A superadmin may act as any account. A caller with an admin-type grant may act as a non-superadmin account whose every role the caller also holds, at the same or a higher type. Anyone else gets a 403. Either way, initiated_by keeps the original caller, so the audit trail shows who asked and who executed.

Invocation roles are checked against the caller’s current grants in the database, never the role claims in a token. An empty invocation_roles list is open to every authenticated caller.

getCredential(provider) resolves in order:

  1. The executing principal’s stored credential. Expired tokens with a refresh token are refreshed.
  2. The initiator’s credential, when execute_as is active and the service account lacks one.
  3. A system environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY and similar).

If none resolves, it throws MissingCredentialError. The interceptor turns that into an escalation with category: 'missing_credential', so a person can supply the value. The source (user, bot or system) is recorded for audit.

When a workflow asks a person for a password or key, the value reaches the tool without landing in workflow state, logs or an LLM’s context.

  1. The escalation’s form marks the field format: "password". The dashboard renders a masked input.

  2. On resolve, each password value is encrypted with AES-256-GCM into lt_ephemeral_credentials with a 15-minute TTL, and the payload carries eph:v1:password:<uuid> in its place.

  3. The workflow and any LLM see only the token.

  4. Right before a tool is called, the token is exchanged atomically for the plaintext and use_count increments. Exchange happens in the MCP tool executor, the triage executor and YAML workflow workers.

  5. After the TTL or max_uses, the row is deleted. An expired token passes through unchanged and the tool rejects it.

Property Value
Format eph:v1:<label>:<uuid>
Encryption AES-256-GCM
Default TTL 900 seconds
Max uses Unlimited by default, configurable

The same keystore mints badge grants (eph:v1:acting_identity:<uuid>) for station devices. See Scan codes.

Identity says who. Scope says what a member sees and does inside one role’s queue. Two axes live on each member grant in lt_user_roles:

Axis Values Governs
read_scope self, all (default) Which rows the member sees
write_scope none, self, all (default) Which rows the member may claim, resolve and cancel

self means rows where assigned_to is the member. Write must be within read. admin and superadmin grants always work the whole queue.

read write Profile
all all Full worker (default)
all self Sees the whole queue, acts on own items
self self Own items only
all none Read-only observer
self none Read-only view of own items

Scope folds into the SQL. A member sees a row when role ∈ allRoles OR (role ∈ selfRoles AND assigned_to = me), and write scope folds into the atomic resolve and the by-id claim, resolve and cancel paths. assigned_to is indexed, so self scope scales.

A workflow can give one person exactly one item. Pass assignee when you create the row, which writes assigned_to in the same insert, and provision that person as a member of the role with read_scope: 'self' and write_scope: 'self':

workflows/confirm-address.ts
const reply = await conditional<{ confirmed: boolean; address: string }>(signalId, {
role: 'customer-confirm',
assignee: userId,
description: 'Confirm your shipping address',
});

They land on that one form with no view of the rest of the queue. A follow-up is another escalation to the same person. Because self scope keys off assigned_to, the assignment holds until the workflow resolves it, independent of any claim window. Add durationMinutes to make the row born claimed.

A persona is a named bundle of roles, each with a relationship. Assigning it adds the user to every linked role at that scope in one step, and each role’s pins and forms compose the user’s dashboard.

Relationship read write
write-all all all
write-self all self
read-all (alias write-none) all none
await lt.personas.seed([
{
key: 'shift-lead',
title: 'Shift lead',
description: 'Works intake and review, watches packing.',
roles: [
{ role: 'intake', relationship: 'write-all' },
{ role: 'review', relationship: 'write-all' },
{ role: 'packing', relationship: 'read-all' },
],
},
]);

Personas are not a second authorization path. Assignment writes ordinary lt_user_roles rows, each marked with granted_by_persona:

  • Unassigning removes only the rows the persona sustains. A row another persona also grants is re-homed to it.
  • Highest allowance wins across personas and direct grants.
  • Re-assigning reconciles memberships against the persona’s current links. Editing a persona reconciles every holder in the same transaction.
  • A direct grant on a persona-sustained row takes it over, so a later unassign leaves it alone.

The seed is idempotent: links absent from the spec are pruned and every holder is reconciled. The same operations are under Admin > Personas, at /api/personas, in ltc personas and in the *_persona MCP tools.

Credential Scope Lifetime Revocation
User JWT Full API, RBAC-scoped 24 hours Logout or expiry
Bot API key Full API, RBAC-scoped Until revoked A builder revokes the key
Delegation token Specific scopes 5 minutes (max 1 hour) Expires
OAuth token Provider scopes Provider-set The user revokes it

Task records carry initiated_by, principal_type and executing_as, so “every workflow ci-bot executed” or “everything initiated by this user and executed by that bot” is one query.