Project

Changelog

Release notes: packages, performance, log export and live event streaming, and the CLI and API surface.

Release notes are kept in docs/changes/ in the repository and rendered here. Apply the listed migrations before deploying a release.

package import, history and removal

Packages are now easy to find, every change to them is on record, and a package can be removed together with everything it installed.

New

  • Packages in the navigation. Automations → Packages (permission packages.view) next to Workflows, Integrations and Agents; Settings → Packages stays.
  • Import by drag and drop. Drop a .utpkg anywhere on the drop zone of the Installed tab — or Import package to choose one. With nothing installed the page is one large drop zone. The import runs the existing wizard: Package (verification) → Changes (the plan) → Configure → Done.
  • Install in another workspace. A package authored here can be installed into another workspace you belong to (and hold packages.install in) straight from Authored here, through the same wizard against that workspace.
  • History. A timeline of every install, upgrade, reinstall, downgrade, reconfigure (Complete setup) and uninstall, failed attempts included: when, who, channel (app / CLI / API / MCP), source, version from → to, signature status, items created / updated / removed / kept, rows, runs cancelled, requirement changes, options and errors. Filters (package, action, status, user, dates), a detail drawer with the full plan / diff that was applied (credentials never stored), and CSV export. Per package from its card. Earlier installs are reconstructed from the audit log.
  • Remove a package and all its artifacts. The uninstall dialog lists every artifact and what will happen to it (with row counts), and offers Remove everything including data tables and their rows (typed confirmation of the package name) and Also remove items changed since install, each with its warning. Unfinished runs of the package's workflows are listed and block the uninstall unless you cancel them in the same step. Missing delete permissions are reported before anything is removed. A summary per artifact follows, and the uninstall is in the history.
  • REST: GET /api/v1/package-events, GET /api/v1/package-events/{id}; POST /api/v1/package-installs/{id}/uninstall gains cancel_runs and its dry run returns active_runs, missing_permissions and blockers.
  • MCP: packages_history, packages_history_get; packages_uninstall gains cancel_runs.
  • CLI: usertasks packages history [package] [--action] [--status] [--channel] [--user] [--since] [--until], usertasks package-events get <id>; packages uninstall --cancel-runs (and the dry run exits 7 when something blocks it).

Changed

  • Uninstall refuses up front when a section's delete permission is missing (it used to remove what it could and drop the install record).

Upgrade notes

  • Apply migration 20261113000000_package_events.sql (new service-role-only table package_events, backfilled from the audit log; idempotent). Until it is applied, installs still work but no history is recorded.

optional drivers guide

A new page, Optional drivers, explains how to install the Snowflake, Oracle and MongoDB drivers and the other optional packages (storage, email, secrets and KMS SDKs, and the SAML library), for installs from source, the Docker image, Kubernetes, Vercel and air-gapped networks. It also covers the Snowflake, Oracle and MongoDB connection details.

Changed

  • Install messages say to rebuild. A missing optional package is replaced by a stub when the app is built, so restarting after npm install was not enough. Every "needs the optional package" error (connector Test connection and queries, storage, email, secrets, KMS and SAML) now gives the version to install, says to rebuild and restart, names the Docker build arg, and links to /docs/self-hosting/optional-drivers: > The Snowflake connector requires the optional package snowflake-sdk; > install it with npm install snowflake-sdk@^2.4.0, then rebuild > (npm run build) and restart (Docker: rebuild the image with > --build-arg INCLUDE_OPTIONAL_DB_DRIVERS=true). See > /docs/self-hosting/optional-drivers.
  • One list of optional packages (lib/optional-drivers), used by next.config.ts and by the error messages. A test checks it against package.json, the stubs and the Dockerfile build args.
  • Docker Compose passes INCLUDE_STORAGE_SDKS and INCLUDE_PROVIDER_SDKS to the image build, as well as INCLUDE_OPTIONAL_DB_DRIVERS.

What the image includes by default is unchanged: the three database drivers are left out unless you build with INCLUDE_OPTIONAL_DB_DRIVERS=true, and the storage, email, secrets, KMS and SAML packages are included. No migrations.

first-class AWS

Every infrastructure area now has a native AWS option that uses IAM instead of long-lived keys or shared passwords, with a Terraform reference profile and an aws Docker Compose profile against LocalStack. Nothing changes unless you opt in: passwords, AUTH tokens and CRON_SECRET keep working, and the Supabase defaults are untouched.

Added

  • RDS / Aurora / RDS Proxy IAM database auth: DATABASE_AUTH=aws. 15-minute tokens from @aws-sdk/rds-signer (optional dependency, loaded on first use), signed with the instance's credentials for the URL's host, port and user, reused for 10 minutes and renewed before they expire. TLS is on and verified against DATABASE_SSL_CA_FILE or the RDS CA bundle now shipped in the image (certs/rds-global-bundle.pem). Applies to every connection the app, the worker and usertasks-admin db migrate open. DATABASE_AWS_REGION overrides the region read from the host name.
  • DATABASE_SSL_CA_FILE (any mode): a CA bundle for the database's server certificate.
  • ElastiCache / MemoryDB IAM auth: CACHE_REDIS_AUTH=aws_iam. SigV4-presigned connect tokens (CACHE_REDIS_AWS_CACHE_NAME, CACHE_REDIS_AWS_REGION, CACHE_REDIS_AWS_SERVICE, CACHE_REDIS_AWS_SERVERLESS), re-sent on the open connection before they lapse; TLS by default.
  • Amazon CloudWatch Logs log destination (aws_cloudwatch_logs). PutLogEvents within CloudWatch's limits (1 MB with 26 bytes per event, 10,000 events, 24 hours per batch), time-ordered per batch, throttling retried, optional log group / stream creation. Credentials: an IAM access key stored encrypted (either value may be a secret://tenants/<workspace id>/… reference), or the instance's role with LOG_EXPORT_ALLOW_INSTANCE_IDENTITY=true. LOG_EXPORT_AWS_ENDPOINT_URL for VPC endpoints and LocalStack. Migration 20261111000000 widens the destination type check.
  • Amazon Cognito OIDC preset ("preset": "cognito"): issuer from userPoolId, hosted UI / managed login domain, cognito:groups as the default groups claim for roleMapping.
  • Cognito-verified tick: SCHEDULER_COGNITO_USER_POOL_ID, SCHEDULER_COGNITO_ALLOWED_CLIENTS and SCHEDULER_COGNITO_SCOPE accept access tokens from an EventBridge API destination whose connection uses OAuth client credentials. node worker.cjs --once no longer warns under SCHEDULER_PROVIDER=external (EventBridge Scheduler → ECS RunTask).
  • deploy/aws/terraform: ECS on Fargate + ALB, RDS PostgreSQL 16 with IAM auth, ElastiCache Valkey with an IAM user, S3, SES, Secrets Manager, a KMS key, CloudWatch log groups, ECR, and an EventBridge Scheduler tick (or API destination, or worker service). Checked with terraform validate; not applied to a live account.
  • npm run stack -- up|smoke --profile aws: LocalStack (S3, Secrets Manager, KMS, CloudWatch Logs) with task-role credentials, SES v2 and Cognito stand-ins; a release gate like the other profiles. The azure and gcp profiles now also smoke-test Azure Monitor and Cloud Logging log export against stand-ins.
  • Catalogue: an aws flag on every provider, usertasks-admin providers list --aws, an AWS badge on Settings → Infrastructure, and a doctor auth check for token database auth (the rds_iam grant and TLS).
  • Docs: AWS setup, the AWS topology now native, and the services pages.

Notes

  • The new optional packages are @aws-sdk/rds-signer and @aws-sdk/credential-providers (dropped with INCLUDE_PROVIDER_SDKS=false).
  • RDS and ElastiCache IAM tokens are not verifiable locally; they are tested with a fake signer against PostgreSQL and Valkey.
  • Tokens, keys and signatures are never logged or put in error messages.

instance housekeeping

System tables that grow on their own are now purged little and often, by default, with settings an instance admin can change in the CLI or on Settings → Infrastructure. See Infrastructure: Housekeeping.

Why: production's cron.job_run_details reached 138 MB for about 18,000 rows. Every pg_cron run inserts into it, the daily purge (rows older than 2 days) let a day of dead rows pile up between runs, and the table belongs to supabase_admin, so we can't VACUUM FULL it. Each insert had slowed to ~0.25 s, adding to the memory and connection stalls on the database.

Added

  • Housekeeping pass (app_cron.run_housekeeping(), migration 20261109500000_instance_housekeeping.sql): deletes cron.job_run_details rows older than 1 day, net._http_response rows older than 1 hour, and expired rows of the cache store (app_cache.entries: rate limits, idempotency claims) and built-in auth (app_auth.sessions, tokens, flow_states, replay_cache). At most 5,000 rows per statement, each in its own subtransaction with lock_timeout = 2s, stopping at a time budget and continuing next pass. Tables that aren't installed (no pg_cron, pg_net, …) or that the role may not delete from are reported, not failed. Every pass is logged in app_cron.housekeeping_runs (30 days).
  • Settings (housekeeping.* instance settings, defaults in brackets): enabled (true), interval_minutes (60; 15 … 1440), cron_history_hours (24; 1 hour – 90 days), http_response_minutes (60; 5 minutes – 7 days). Invalid stored values fall back to the default.
  • Every scheduler. pg_cron: usertasks-housekeeping every 15 minutes (runs when the interval is due) and usertasks-cron-housekeeping, now the daily forced pass with a larger budget. Worker / external: the job registry's housekeeping (every 15 min) and housekeeping-daily (03:30 UTC), which call the same function; /api/cron/housekeeping runs a pass on demand.
  • usertasks-admin housekeeping show | set <setting> <value|default> | run [--dry-run]; run reports rows deleted per table. status prints a housekeeping line, and status / doctor warn about a late or disabled pass and about large or bloated purged tables (with the hint that a Supabase-owned table needs VACUUM FULL by its owner — Supabase support).
  • Settings → Infrastructure → Housekeeping (instance admins): the settings with validation, each table's size and row estimate, recent passes, Run now and Dry run. Saving and running are server actions that re-check instance-admin access; a workspace owner is refused.

Changed

  • usertasks-cron-housekeeping keeps its name and 03:30 UTC slot but now runs app_cron.run_housekeeping('pg_cron-daily', …); the old app_cron.housekeeping(interval, interval) stays for compatibility and is no longer scheduled. pg_cron run history is kept 1 day instead of 2.
  • npm run scheduler -- install-pg-cron / switch scheduler --to pg_cron install three jobs and require the new migration.

Not changed

Tenant data: queue history, the event_stream outbox and OAuth rows stay with the daily cleanup-logs job and per-workspace retention; workspace content with Retention Policies. The provider switch log is kept.

Deploying

Apply 20261109500000_instance_housekeeping.sql (it adds objects, schedules the pg_cron job and runs one bounded pass — seconds on production). The app and the migration can go in either order: before the app update the new pg_cron job already purges; before the migration the registry jobs and the Infrastructure card report "not installed". The disk space cron.job_run_details already holds is only returned by VACUUM FULL by supabase_admin (Supabase support); housekeeping keeps it from growing.

LDAP, Google Workspace and SCIM directory sync

Directory sync grows from Entra ID and Okta to every common directory, and an on-premises one: LDAP / Active Directory (pulled, with nested groups and delta syncs), Google Workspace (pulled) and SCIM 2.0 (pushed by the identity provider). With built-in auth, an LDAP connection can also let people sign in with their directory password. Existing Entra ID and Okta connections keep working unchanged.

Migration

20261109000000_directory_sync_providers.sql (idempotent, apply before deploying the app):

  • directory_links.provider accepts ldap, google, scim; directory_sync_runs.provider accepts ldap, google, and runs record mode (full / delta);
  • scim_tokens, scim_resources, scim_group_members (RLS on, service role only);
  • cron_tick_probe() counts enabled LDAP and Google connectors for the directory-sync job (same function otherwise).

Added

  • LDAP / Active Directory connector: LDAPS or StartTLS with a per-connection CA bundle and certificate checks, paged searches, Active Directory / OpenLDAP / generic presets with an editable attribute mapping and filters, nested groups (AD LDAP_MATCHING_RULE_IN_CHAIN, recursion elsewhere), delta syncs (uSNChanged / modifyTimestamp) between daily full syncs, deprovisioning of users who leave the directory, and Test connection with a mapped sample and warnings.
  • LDAP bind login (built-in auth, opt-in per workspace): directory users sign in with their LDAP password; other names keep the local password.
  • Google Workspace connector: Admin SDK Directory API with a service account and domain-wide delegation; users, groups and derived (nested) membership.
  • SCIM 2.0 server at /scim/v2: Users and Groups (GET / POST / PUT / PATCH / DELETE, .search), filters, paging, attribute projection, weak ETags, discovery endpoints; tested with the request shapes of Microsoft Entra ID's and Okta's SCIM validators. Per-workspace bearer tokens (SHA-256 at rest, scopes, expiry, rotation with a grace period, rate limits).
  • Per-connector sync_interval_minutes (default 60, minimum 10).
  • Credentials may be secret://tenants/<workspace id>/<name> references to the instance secret store; other references are refused.
  • REST /api/v1/directory-sync/connections[/{provider}[/test|/sync]], /api/v1/directory-sync/runs, /api/v1/directory-sync/scim-tokens[/{id}[/rotate]]; CLI usertasks directory-sync list|get|connect|update|disconnect|test|sync|runs and usertasks scim-tokens list|create|delete|rotate; MCP directory_sync_* and scim_tokens_* (reads need settings.view, changes settings.edit).
  • Settings → SSO & Directory: LDAP, Google Workspace and SCIM cards; LDAP settings can be edited after connecting; Full sync for LDAP.

Changed

  • The SSO & Directory page's server actions return { ok, error } results (messages survive production builds) and go through the shared service, so connecting Entra ID or Okta is audited and verified exactly as through the API.
  • New dependency: ldapts.

Notes

  • LDAP servers on private addresses need ALLOW_PRIVATE_EGRESS=true (as for integrations); cloud metadata addresses stay blocked.
  • For Active Directory delta syncs, point the URL at one domain controller: uSNChanged is per DC.
  • SCIM DELETE and active: false remove access in the workspace but never delete the account; deleting a SCIM group keeps its team (SCIM-managed members leave it).

pick values from API responses

Reading values from a REST reference used to mean hand-writing JSON paths and working out loops yourself. References now carry a response model, and the workflow designer has a data picker that browses it.

New

  • Response models on references. The shape of a reference's response — nested objects, lists with their item fields, types, descriptions and example values — stored at request_config.response_schema. It comes from the OpenAPI/Swagger 2xx response schema on import ($refs resolved), from Test in the reference builder (inferred from the real response; further samples merge in, so optional fields show up), from table columns for database references and from the connector catalogue. The builder shows it as a tree with Capture from test response, Reset and + to add a named output with the right path.
  • Data picker. Every expression field in the node settings has an Insert data button, and Variables → Data shows the same tree: only steps upstream of the selected step, each with its outputs, following response models level by level (Get Customers › response › data[] › address › city), plus Current item inside a loop body. Search, type icons, example values; arrow keys and Enter.
  • Single value vs collection. A list or a field inside one offers First item, Item at index…, Find item where…, All values of a field as a list, Count and Loop through each item (wraps the step in a Loop For Each and inserts {{item.<field>}}).
  • Loop items autocomplete. Items to loop through suggests every list available at that step (response lists, data-table rows, set-variable and split results, workflow variable lists, agent and custom-step list outputs, the current item's lists for nested loops), grouped by step with an example item. Free text still works; a value known not to be a list shows a warning.
  • HTTP Request response model. Paste a sample response to make an HTTP Request's fields pickable.
  • Named outputs from the model. In a Call Reference node, + on a field of the response model adds custom_outputs ({ name, path }).
  • Functions: FIND_WHERE(list, field, value[, return_field]) and FILTER_WHERE(list, field, value); PLUCK accepts dotted paths ("address.city").
  • Index paths: {{data[0].name}} works as well as {{data.0.name}}.
  • API / MCP / CLI: GET /api/v1/references/{id} and MCP references_get return response_schema (derived from outputs when none is stored); create/update accept response_schema (null clears it); POST /api/v1/references/{id}/execute and MCP integrations_execute_reference take capture_schema: true to fold the response into the model (usertasks references execute <id> --set capture_schema:=true).

Unchanged

  • Existing output_mappings and outputs keep working exactly as before.
  • References without a model derive one from their outputs' paths until their next test. No database migration.

packages and export coverage

A designer can now package the artifacts that make up a process and deploy them to other environments as one signed file, and every artifact type can be exported and imported on its own.

New

  • Packages. Pick a root artifact (usually a workflow); everything it uses is included automatically — sub-workflows, custom steps, decision tables, data tables, integrations with their references and objects, connectors, email templates, agents, custom roles, retention policies, MCP servers — each with the reason it is there. Add or leave out items and choose schema only or schema + rows per data table. Build version exports the current state with secrets removed, signs it (ed25519) and stores it as a published version; the .utpkg file (ZIP: manifest.json, the config-as-code layout under assets/, rows as NDJSON, README.md) can be downloaded again at any time.
  • Requirements. Every credential, secret, ${env:} reference, environment-specific URL / host, assignee and built-in role a package depends on becomes a requirement. Installs ask for the values — or install incomplete: connections that need configuration are created disabled and the workflows that depend on them cannot be activated or published until Complete setup (the designer shows why).
  • Package library (Settings → Packages): installed packages with version, source, signer and Complete / Incomplete status with a checklist, and Complete setup, Upgrade and Uninstall. The install wizard verifies the file, shows the dry-run plan, asks for requirement values and installs.
  • Upgrade in place. Installing a newer version updates what the earlier install created (field diffs first), keeps local credentials and requirement values, flags assets changed locally (optionally keeps them) and assets removed from the package (never deleted), merges rows.
  • Uninstall lists what will be removed first; locally changed assets and data tables with rows are kept unless forced.
  • REST: /api/v1/packages (list, create, get, update, delete, /contents, /preview, /inspect), /api/v1/packages/{id}/versions (build, list, get, /download) and /api/v1/package-installs (list, get, /plan, install, /{id}/upgrade, /{id}/complete, /{id}/uninstall).
  • MCP: packages_list, packages_get, packages_preview, packages_create, packages_update, packages_delete, packages_contents, packages_versions_list, packages_build, packages_version_get, packages_download, packages_inspect, packages_installs_list, packages_installs_get, packages_install_plan, packages_install, packages_upgrade, packages_complete_setup, packages_uninstall.
  • CLI: usertasks packages list|create|add|remove|contents|build|download|inspect|install|upgrade|complete|uninstall|installs. install prompts for missing values on a terminal (secrets without echo); in scripts use --set <requirement>.<field>=value, -f values.yaml and ${env:VAR}, or --keep-incomplete. inspect verifies a file offline.
  • Permissions: packages.view, packages.manage, packages.install (Owner, Admin and Manager by default). OAuth scopes packages:read / packages:write; config:read includes packages.view and config:write all three.
  • Export coverage. Every artifact type exports and imports on its own: the generic Export / Import buttons on list and detail pages, GET /config/export?ids=…&with_dependencies=true&include_rows=true, MCP config_export / config_apply and usertasks config export --ids … --with-dependencies --include-rows. Data-table rows travel as the table_rows section (data-rows/ folder) and apply with rows_mode merge / replace / skip. See docs/export-coverage.md.

Fixed

  • notification_template_id (user-task notification email templates) and retention-trigger policy_ids are remapped on import.
  • Decision-table export files from the editor and from REST/MCP import into each other.

Upgrade

Apply 20261103400000_packages.sql (tables, permissions for the system roles, CLI OAuth scopes) before deploying. Set ENCRYPTION_KEY (or PACKAGE_SIGNING_KEY) so packages are signed. API keys created before this release do not have the packages.* permissions; add them to keys that should build or install packages.

admin CLI and Infrastructure page

Operators can now move a running install between providers with one command per area, and see the instance's infrastructure in the app. Nothing changes for installs that don't use it: the Supabase providers stay the defaults.

Added

  • usertasks-admin status, providers list, doctor: the provider per area with its source and health; the provider catalogue with on-prem / Azure / Google Cloud flags (--on-prem, --azure, --gcp); and connectivity / configuration checks per area — database roles, privileges, extensions and migrations, a storage write probe, Supabase Auth health or the built-in store, OIDC discovery, email configuration and an optional test send, pg_cron jobs / worker leader / last tick, a LISTEN/NOTIFY round trip, secret:// resolution. See Switching providers.
  • usertasks-admin switch <area> --to <provider> for the database (copy into any PostgreSQL 15+, row-count and RLS verification), auth (Supabase accounts with their passwords into the built-in store), storage (checksum- verified copy and stored-URL rewrite), the scheduler (pg_cron jobs in or out), and realtime / email / secrets (validated config changes). Every switch has pre-flight checks, a dry run, a refusal without --yes, an automatic backup (config snapshot, row snapshots, pg_dump), a per-step log, idempotent re-runs and usertasks-admin rollback <switch-id>. history lists the switch log; instance-admins manages who sees the Infrastructure page.
  • Settings → Infrastructure (read-only, instance admins only — INSTANCE_ADMIN_EMAILS or the instance.admin_emails setting, matched on the account's verified email; workspace roles don't grant it): provider per area with badges, cached health checks, the recent switch log, links to the docs.

Migration

  • 20261107000000_instance_admin.sql: allows instance.<option> keys in instance_settings and adds instance_provider_switches (the switch log; service role only, like instance_settings). Safe on any install.

Notes

  • Backups (BACKUP_DIR, default ./backups/switches) hold config snapshots and, for auth switches, password hashes: they are created private (0700) and belong with your other database backups.
  • The Docker image's admin.cjs now also loads the optional storage / email / secrets SDKs from the app's node_modules.

database portability

UserTasks' schema now installs on any PostgreSQL 15+ — plain Docker postgres, Amazon RDS / Aurora, Azure Database for PostgreSQL, Google Cloud SQL / AlloyDB, Neon, CloudNativePG or Patroni clusters — not only on Supabase. With DB_PROVIDER=postgres and the other non-Supabase providers, an install no longer needs Supabase at all. Supabase installs are unchanged.

Added

  • usertasks-admin db migrate | status | repair (new package packages/admin; npm run admin -- … in a checkout, node admin.cjs … in the Docker image, which now ships supabase/migrations/). Applies the same migration files as supabase db push, one transaction per migration, serialised by an advisory lock, with dry-run, out-of-order protection and checksum warnings. History is kept in Supabase's supabase_migrations.schema_migrations format. See Infrastructure: Database.
  • 20261014000000_compat_bootstrap.sql: on non-Supabase PostgreSQL it creates the API roles, the auth.uid() / role() / jwt() / email() helpers over request.jwt.claims, a minimal auth.users and the extensions schema. A no-op on Supabase.
  • DATABASE_AUTH=azure|gcp: Microsoft Entra ID tokens for Azure Database for PostgreSQL and IAM access tokens for Cloud SQL / AlloyDB as the connection password, for the app's pools, the realtime LISTEN connection, the scheduler worker and usertasks-admin.
  • Managed PostgreSQL without BYPASSRLS: when service_role can't be given BYPASSRLS, the postgres db provider runs service-role requests as the schema owner (which bypasses RLS on its tables) with the same claims.
  • A CI job runs the migrations and the full test suite on plain PostgreSQL 16 and 17, as a superuser and as a managed-style owner, with every Supabase-free provider. Tests of Supabase services skip there by capability detection.

Changed

  • Storage bucket / policy statements and the original pg_cron jobs in the migrations are skipped where Supabase Storage or pg_cron is not installed.
  • Workspace theme and logo saves no longer construct the storage provider just to decide how a reference is stored (a misconfigured local root no longer fails a save that uploads nothing).

Upgrade

  • Supabase projects (production): once, before the next push, npx supabase migration repair --status applied 20261014000000 --linked (see Database migrations). Nothing else changes.
  • Plain PostgreSQL: create a database, then usertasks-admin db migrate. See the privileges table in Infrastructure: Database.

auth providers

Sign-in moves to the server, and UserTasks can run its own accounts and sessions — or hand sign-in to any OIDC or SAML identity provider — instead of Supabase Auth. Nothing changes for existing installs unless you opt in: Supabase Auth stays the default.

Changed (everyone)

  • Server-side sign-in. The login, forgot-password and reset-password pages and the beta signup now post to server actions; sign-out is a server action too. Session cookies are set by the server and are HttpOnly (plus SameSite=Lax, and Secure on https). The browser no longer loads an auth client at all, and no longer loads supabase-js unless Supabase Realtime is in use (which now gets a short-lived token from the server).
  • The dashboard checks the session with the server (GET /api/auth/session) at boot and on a light heartbeat, and signs out other tabs over a BroadcastChannel. POST /api/auth/session (setting cookies from browser tokens) is gone.
  • The login page shows a message for SSO / invitation errors (?error=no_invite, sso_failed, …).
  • Migration 20261106000000_auth_portability.sql: the foreign keys that pointed at auth.users (users.id, user_roles.user_id, agents.created_by, reports.created_by, workspace_themes.updated_by) now point at public.users (or are dropped for users.id); deleting a Supabase auth user still deletes the profile (a trigger replaces the cascade). The personal-workspace provisioning the on_auth_user_created trigger does is now an idempotent function, public.provision_auth_user, shared with the app. Row-level security is unchanged.

Added

  • AUTH_PROVIDER=builtin: email + password accounts in the app's Postgres (app_auth, migration 20261106100000_builtin_auth.sql), bcrypt (Supabase-compatible), invitation-only sign-up by default, confirmation and reset emails through EMAIL_PROVIDER, opaque server-side sessions with idle / absolute expiry and revocation, rate limits and account lockout.
  • Single sign-on with any number of IdPs (AUTH_SSO_PROVIDERS), next to passwords or alone (AUTH_PROVIDER=oidc / saml):
    • OIDC (code + PKCE, state, nonce, JWKS-verified ID tokens) with presets for Microsoft Entra ID (single and multi-tenant), Entra External ID, Google / Google Workspace, Okta, Auth0, Keycloak, Authentik, ZITADEL, Dex and AD FS;
    • SAML 2.0 (SP- and IdP-initiated, signed assertions, replay protection, SP metadata) with presets for AD FS, Entra ID, Okta, Google Workspace, Keycloak, Shibboleth, OneLogin and PingFederate;
    • just-in-time accounts, email-domain allowlists, linking by verified email, a default organisation and role, and IdP group → role mapping.
  • npm run auth -- migrate-users copies Supabase Auth users (ids, passwords, confirmation, Google / Azure identities) into built-in auth; npm run auth -- redirect-uris prints the URLs to register at each IdP.

Upgrade notes

  • Apply both migrations. With the default provider nothing else is needed.
  • Users stay signed in across the upgrade (their Supabase cookies are reused and become HttpOnly on the next refresh).
  • Switching to built-in / OIDC / SAML: see Infrastructure → Auth. Everyone signs in again after a switch; passwords carry over.

email and secrets providers

App email no longer needs Resend, and provider credentials no longer need to sit in environment variables. Nothing changes unless you opt in: Resend and environment variables stay the defaults.

Added

  • EMAIL_PROVIDER: smtp (any relay — Exchange, Postfix, Microsoft 365, Google Workspace SMTP relay — with implicit TLS or STARTTLS, auth and certificate checks), ses (Amazon SES), azure_communication (Azure Communication Services, connection string or managed identity), microsoft_graph (Graph sendMail, client secret or managed identity), gmail (Gmail API with domain-wide delegation) and console (log + .eml files). See Infrastructure and providers.
  • EMAIL_REPLY_TO: default Reply-To for app email.
  • SECRETS_PROVIDER: env (now also <VAR>_FILE and a secrets directory), db (encrypted instance settings), supabase_vault, hashicorp_vault (also OpenBao; token, AppRole and Kubernetes auth), azure_key_vault, gcp_secret_manager and aws_secrets_manager.
  • secret://<name>[#field] references in any provider option (env variable or instance setting), resolved at runtime with a cache (SECRETS_CACHE_TTL_SECONDS, default 300 s) so rotations are picked up without a restart. See Secrets.
  • ENCRYPTION_KEY from the secret store: ENCRYPTION_KEY=secret://…, or the store's encryption_key when the variable is unset.

Changed

  • Every app email goes through the email provider (lib/email.ts); the Resend path sends exactly as before.
  • Instance settings refuse plaintext credentials for email and secrets options too (storage already did); secret:// references are accepted as plain values.
  • The secrets provider id supabase-vault (reserved, never implemented) is now supabase_vault.

Upgrade

No migration. To switch, set the variables and restart.

scheduler provider

Background jobs no longer need pg_cron. The app can tick itself, with any number of replicas, or be driven by any outside scheduler — including Google Cloud Scheduler (OIDC) and Azure Container Apps Jobs / Functions (Entra ID managed identity) without a shared secret. Nothing changes unless you opt in: pg_cron stays the default and calls the same endpoint as before.

Added

  • SCHEDULER_PROVIDER=worker: an in-process scheduler that runs the job registry every minute without HTTP, pg_cron or Vault. It starts inside the app server (Node.js / Docker; not Vercel) or as its own process: npm run worker, or node worker.cjs in the Docker image (--once for a single tick). Replicas elect one leader with a Postgres advisory lock (SCHEDULER_DATABASE_URL / DATABASE_URL), or through the tick lease when only Supabase is reachable. SCHEDULER_IN_PROCESS=false keeps web replicas out of it.
  • SCHEDULER_PROVIDER=external, documented for OS cron, systemd timers, Kubernetes CronJobs, Vercel Cron, Google Cloud Scheduler and Azure Container Apps Jobs / Functions timers / Logic Apps.
  • Token auth for /api/cron/tick: Google-signed OIDC ID tokens (SCHEDULER_OIDC_SERVICE_ACCOUNTS, SCHEDULER_OIDC_AUDIENCE) and Entra ID tokens (SCHEDULER_ENTRA_TENANT_ID, SCHEDULER_ENTRA_AUDIENCE, SCHEDULER_ENTRA_ALLOWED_PRINCIPALS) are accepted in addition to the cron secret when configured.
  • npm run scheduler -- status | install-pg-cron | uninstall-pg-cron: (re)installs the pg_cron jobs exactly as the migrations do and stores the base URL and cron secret in Vault from the app's configuration, or removes them after switching.

Changed

  • The app warns in its log when pg_cron still calls the tick while another scheduler provider is active.
  • The Docker image also contains worker.cjs.

Upgrade

No migration. To switch, see Infrastructure and providers.

storage provider

Uploaded files can now live outside Supabase Storage: on local disk (or an NFS / SMB volume), in S3 or any S3-compatible store (MinIO, Ceph RGW, …), in Azure Blob Storage or in Google Cloud Storage. Nothing changes unless you opt in: the default stays Supabase Storage, with the same public URLs.

Added

  • STORAGE_PROVIDER=local|s3|azure_blob|gcs with options as environment variables or encrypted storage.<option> instance settings: bucket mapping (STORAGE_BUCKET_MAP, STORAGE_BUCKET, STORAGE_BUCKET_PREFIX), an optional direct / CDN base URL (STORAGE_PUBLIC_BASE_URL), and per provider credentials — including managed identity / workload identity for Azure (Entra ID) and Google Cloud (Application Default Credentials). See Infrastructure and providers.
  • GET /files/<bucket>/<key> serves stored files through the provider, with public caching, ETags, a sandboxing CSP and attachment downloads for anything that isn't an image, font, PDF, text, audio or video.
  • storage://bucket/key references. With a non-default provider, the database stores references for logos, theme assets and icons; data-table and workflow file values keep a usable url and gain ref.
  • Migration building blocks (lib/storage/migrate.ts): checksum-verified object copy between any two providers, and a dry-run-first rewrite of stored URLs to references.

Changed

  • Every server-side storage call (uploads, workflow-generated files, theme fonts, data-table file clean-up) goes through the storage provider.
  • Theme, logo and font validation accepts storage:// references and the app's /files URLs as well as Supabase public URLs of this project.
  • Workflows that read the app's own files read them through the provider (no HTTP round trip) when the provider isn't Supabase.
  • The org switcher no longer crashes when one of your memberships points at a workspace you can't read: such memberships are skipped, and the switcher shows "Unavailable workspace" if it is the active one.

Upgrade

No migration. To switch: create the buckets, copy the objects and rewrite stored URLs (see the infrastructure page), set STORAGE_PROVIDER and its options, restart.

realtime provider

Live updates can now run without Supabase Realtime. Nothing changes unless you opt in: the default stays Supabase Realtime.

Added

  • REALTIME_PROVIDER=sse. The app pushes live updates (task lists, the task queue, tasks you assigned, task timelines, the notification bell and the live log tail) over Server-Sent Events from /api/internal/realtime, fed by Postgres LISTEN/NOTIFY. One connection per browser tab; each app instance listens on its own, so multiple instances work without a broker. Changes carry ids only and go only to users who could read the row; pages re-read through the normal APIs. See Infrastructure and providers.
  • Migration 20261105000000_realtime_notify.sql adds statement-level NOTIFY triggers on the realtime tables. They stay idle unless an SSE listener is active, and the supabase_realtime publication is unchanged.
  • New variables: REALTIME_DATABASE_URL (session-mode connection for LISTEN; falls back to DATABASE_URL, then SUPABASE_DB_URL, then to polling the tables every 2 s).

Changed

  • Every live-updating hook now subscribes through one useRealtime hook and the realtime provider; with the default provider the Supabase channels and filters are the same as before (the live log tail now uses one channel per source instead of one shared channel).
  • The browser learns the realtime provider from the runtime public config (realtimeProvider, only present when it isn't supabase); in sse mode the browser never loads supabase-js for realtime.

Upgrade

Apply the migration. To switch, set REALTIME_PROVIDER=sse (and REALTIME_DATABASE_URL if DATABASE_URL isn't set) and restart; Supabase Realtime can then be turned off for the project.

run a workflow by hand with its inputs

Workflows can now be started from the workflow list and the designer, with a form for their input parameters. Before this release a manual start needed the API, MCP or the generated workflows start command, and the variables were passed as free-form JSON without any checks.

New

  • Run button. Each row of Automations → Workflows (list and card view) and the designer header have Run. It is shown to people with Activate workflows (workflows.activate) and disabled, with the reason as tooltip, until the workflow is published and active.
  • Run dialog. A form generated from the published version's workflow variables: text, number, checkbox, date, date & time, email, URL, choice list and JSON fields, with defaults filled in, descriptions as help text and required inputs marked and checked. Run in background (default, with a queue priority) or Wait for result (shows the final status and step outputs). The result links to the run (or the queue while the job waits).
  • Input settings in the designer. A workflow variable can be marked Required input, and a string variable refined to date, date & time, email, URL or a choice list (required, format, options in user_variables).
  • REST: GET /api/v1/workflows/{id}/inputs returns the input fields (key, label, type, required, default, description, options) of the published version, or of the draft when never published.
  • MCP: workflows_inputs_get.
  • CLI: usertasks workflows inputs <id> and usertasks workflows run <id> [--input key=value …] [-f inputs.json] [--wait] [--priority <n>], which converts values to the input types and asks for missing required inputs on a terminal. Without a terminal it lists them and exits 2.

Changed

  • POST /api/v1/workflows/start, MCP workflows_start and the generated workflows start validate variables against the declared inputs: a missing required input or a value of the wrong type is a 400 (validation_error) with one details entry per field, and nothing starts. Values are converted to the input type ("42" → 42 for a number input, "true" → true for a boolean, a JSON string → the object for an object input). Variables that are not declared inputs still pass through unchanged and are listed in the new warnings field. Workflows without declared inputs behave as before.
  • The start response also carries workflow_id and published_version_id; async starts still answer 202 with job_id.

Upgrade

No migration. Existing workflow variables have no required flag, so existing callers are only affected if they pass a value that cannot be converted to the variable's declared type (for example "abc" for a number variable).

one scheduler tick

Background jobs now run from a single pg_cron job instead of 14. Nothing changes for users; this is an operations fix.

Why

Each pg_cron run opened its own database connection (with cron.use_background_workers = off), about 7 a minute across the 14 jobs, and cron.job_run_details was never purged (on production: 589k rows, 138 MB, 72% of the database). On a small instance memory crept up over about 11 hours until 44% of runs failed with "job startup timeout" and the database refused connections.

Changed

  • One job, usertasks-tick, calls POST /api/cron/tick every minute. The app works out which jobs are due in that minute (UTC) and runs them in-process, up to CRON_TICK_CONCURRENCY (default 4) at a time, within about 50 s. Cadences are unchanged: | Cadence | Jobs | |---------|------| | Every minute | process-schedules, process-workflows, process-agents, github-sync-push, github-sync-deploy, ship-events | | Every 5 min | process-sla, process-reminders, process-escalations | | Every 10 min | process-deletions, directory-sync, oauth-refresh | | Daily (UTC) | process-retention 03:00, cleanup-logs 04:00 | The list lives in lib/jobs/registry.ts.
  • Idle jobs are skipped. One query per tick (cron_tick_probe()) checks whether each job has anything to do: an empty queue, no GitHub sync or directory connection, no OAuth connector tokens, no enabled log destination, no active schedule or SLA. Skipped jobs cost nothing else.
  • No overlap. A tick holds a lease (app_cron.tick_lock, 55 s). If the previous tick is still running, the next one skips its minute. A daily job whose minute was skipped runs on a later tick that day (within 6 hours). Each job has its own time budget; a job that overruns is reported as timeout and finishes in the background (all jobs claim their work, so nothing runs twice).
  • History. app_cron.tick_runs keeps each job's last start, finish, status, duration and error.
  • Housekeeping. usertasks-cron-housekeeping (daily 03:30 UTC, pure SQL, no HTTP) deletes cron.job_run_details older than 2 days and net._http_response older than 1 hour. The migration also purges the existing backlog once.
  • Endpoints unchanged. /api/cron/process-schedules, /api/workflows/process, /api/cron/ship-events and the rest still work on their own for external schedulers, in-app kicks and manual runs. Their logic moved to lib/jobs/* so the tick and the endpoints share it. /api/cron/process-agents now runs the agent queue directly instead of forwarding to /api/agents/process over HTTP.

Expected effect

pg_cron runs drop from about 7 to 1 a minute (one new connection per minute instead of ~7), plus one daily housekeeping run. cron.job_run_details stays at about 3,000 rows (two days of ticks).

Upgrade

  1. Deploy the app version with /api/cron/tick.
  2. Apply supabase/migrations/20261103100000_cron_consolidation.sql (npx supabase db push, or run it in the SQL editor). It is idempotent.
  3. Check: select jobname, schedule from cron.job where jobname like 'usertasks-%'; shows two rows, and after a minute select * from app_cron.tick_runs; shows runs.

Applying the migration before the deploy stops all background jobs until the deploy lands (the tick gets a 404). Rollback: re-run 20261015000002_cron_jobs.sql and the last block of 20261024000003_log_shipper.sql.

New optional environment variable: CRON_TICK_CONCURRENCY (default 4).

HTML task descriptions

Task descriptions can now be HTML. The app renders the formatting, API clients can ask for plain text, and the CLI shows plain text unless you ask for the HTML. Plain-text descriptions work exactly as before.

New

  • Rich descriptions. A description may use p, br, div, span, hr, strong/b, em/i, u, s, sub, sup, a, ul, ol, li, h1–h4, blockquote, code, pre, table, thead, tbody, tr, th, td and img. Links may be http(s), mailto or relative and always open in a new tab with rel="noopener noreferrer". Images must use an absolute https URL. Everything else is removed on save: scripts, styles, style/class/id and on* attributes, iframes, forms, SVG and MathML, and javascript: or data: URLs. Text that only looks like a tag, such as <customer name>, is kept as text.
  • In the app, task detail, the assigned-task details panel and the workflow test runner render the formatted description. Task cards, the queue and the timesheet notes column show a plain-text excerpt. The New task form has an "HTML supported" hint and a Preview toggle.
  • REST: task responses (GET /tasks, /tasks/assigned, /tasks/{id} and PATCH /tasks/{id}) keep description as stored and add description_text, a plain-text version. Add ?strip_html=true to the three GET endpoints to get plain text in description itself.
  • MCP: tasks_get returns description_text and takes strip_html: true. tasks_create and tasks_update accept HTML.
  • CLI: usertasks tasks list | assigned | get send strip_html=true, and every tasks command converts any HTML description in its output to plain text. Pass --html to see the stored HTML.
  • Workflows: a User Task task_description may be an HTML template. Values interpolated into it are HTML-escaped, so a variable cannot add markup. In a plain-text template, values are inserted unchanged unless they contain <. user_task_description is now the plain-text version (for notification text bodies). The new user_task_description_html holds the HTML for HTML email bodies.

Behaviour changes

  • Every description write is sanitised: the task form, inline edits, PATCH /tasks/{id}, POST /tasks/ingest (including subtasks), tasks_create, tasks_update, User Task nodes and update_task nodes. If an existing integration sends HTML that uses non-allowlisted markup, that markup is removed.
  • In a plain-text User Task template, a value containing < is now stored escaped (&lt;). description_text and the app show the original text.
  • The default assignment notification body, and {{user_task_description}} in notification templates, are plain text even when the description is HTML.

No migration is needed.

migrating live workflow instances

Runs that are waiting (on a User Task, a timed delay or an external event) or paused can now be moved to another version of their workflow. Before this release they always finished on the version they started with.

New

  • Migrate live instances. Pick the target version and, for each node the instances are at, the node where they continue (the node with the same id is pre-selected). The old run is closed with the new status migrated, its open task is cancelled and its queued resumes are dropped. A new run starts on the target version at the chosen node with the same variables (defaults for new variables), task and trace id. A User Task there creates a fresh task. Each instance is handled on its own; instances that changed meanwhile are reported as conflicts and left alone.
  • UI: a N live instances on older versions badge in the workflow designer opens Live instances (current node, version, waiting time, assignee; counts per version, also in Version history). Migrate to version… opens a wizard: target version, target node per current node with a graph preview, dry-run review, typed confirmation above 10 instances, results with links to the new runs. Move to version… on the run page and in the workflow queue moves one run. Run pages show Migrated to → / Migrated from ← banners.
  • REST: GET /api/v1/workflows/{id}/instances (status, version_id) and POST /api/v1/workflows/{id}/instances/migrate (to_version_id, run_ids | from_version_id | all_active, node_map, default_target_node_id, dry_run; up to 500 instances per request).
  • MCP: workflows_instances_list, workflows_instances_migrate (dry_run supported).
  • CLI: usertasks workflows instances <id> and usertasks workflows migrate-instances <id> --to <version> [--run … | --from <version> | --all] [--map src=dst] [--target <node>] [--dry-run], which plans first, prints the plan as a table and asks before migrating.
  • Events: workflow.run.migrated for both runs (attributes migrated_to_run_id / migrated_from_run_id, direction out / in).
  • Permission: workflows.manage_runs (“Migrate live runs”). Owner, Admin and Manager have it; custom roles opt in. The OAuth workflows:write scope includes it.

Changed

  • New run status migrated (terminal). Status filters, badges and reports know it.
  • GET /workflows/{id}/versions returns active_instances per version; GET /workflows/runs returns version_id, migrated_from_run_id and migrated_to_run_id and filters by version_id (MCP workflows_runs_list too).
  • The run page's process view and the queue's Go To Activity dialog now draw the version the run executes. They used to draw the current draft.

Upgrade

Apply 20261103000000_workflow_instance_migration.sql before deploying the app version that has this feature (see docs/migrations.md). API keys created before this release do not have workflows.manage_runs; add it to keys that should migrate instances.

workspace theme designer, API, MCP, CLI and config as code

Each workspace now has one brand theme that every member sees. Before this release, themes were per browser. The theme covers colours for light and dark, fonts and weights, corner radii and shadows, density, sidebar and top bar styles, logos, the favicon and a wordmark. The 43 built-in themes are now starting templates.

New

  • Theme designer at Settings → Theme (/settings/theme). It edits a draft and shows a live preview of the app (sidebar, top bar, task list with highlighted rows, buttons, inputs, status pills, task detail with outcome buttons and a chart) in light and dark. It also has undo and redo, reset to template, and JSON import and export. Colour fields show WCAG contrast badges. Save for workspace asks for confirmation first, because the theme changes for every member right away. If someone else saved in the meantime, you can load their version or overwrite it. Members without settings.edit see the designer read-only. /settings/brand-theme redirects to /settings/theme.
  • Fonts: pick from the curated Google Fonts list or the system stacks, or upload a .woff2 file (at most 1 MB). Uploads go to the new brand-fonts storage bucket, in the workspace's folder, and are served with @font-face.
  • Logos: separate logos for light and dark sidebars, plus a favicon. They are stored in the workspace's logos folder.
  • REST: GET / PUT /api/v1/workspace-theme (PUT takes the whole theme plus an optional expected_version; a mismatch returns 409), POST /workspace-theme/apply-preset, GET /workspace-theme/presets and POST /workspace-theme/fonts (multipart file, or JSON with content_base64).
  • MCP: workspace_theme_get, workspace_theme_presets_list, workspace_theme_update, workspace_theme_apply_preset and workspace_theme_font_upload.
  • CLI: usertasks workspace-theme get | update | presets | apply-preset <preset_id> | upload-font. A file saved with get -o json > theme.json can be sent back with update -f theme.json.
  • Config as code: the singleton section workspace_theme is stored as workspace-theme.json at the root of the config folder.

Permissions

Reading the theme and listing templates is open to every member. REST needs no permission. MCP needs tasks.view_own, which every role has. Changing the theme or uploading a font needs settings.edit.

Behaviour changes

  • usertasks config export and GET /config/export now include workspace-theme.json when no types / ids filter is given. Plans therefore show one more asset, workspace_theme. Applying a config folder that contains the file replaces the theme of the target workspace, which needs settings.edit. A dry run lists that requirement under missing_permissions.
  • When a theme is applied in another workspace or environment:
    • Uploaded fonts are matched by file name in the target workspace's brand-fonts folder. If the file is missing, that font falls back to the default and the apply returns a warning. Upload the file there and apply again.
    • Logo and favicon URLs that are not the target workspace's own uploads are dropped, with a warning.
  • GitHub sync push and deploy do not carry the theme yet. Only config export / config apply do.
  • Generated CLI commands can now take body fields as positional arguments (workspace-theme apply-preset <preset_id>).

Database

20261102000000_brand_fonts_bucket.sql adds the public brand-fonts bucket. It accepts font/woff2 only, up to 1 MB. Writes need membership plus settings.edit for the path's tenant folder, the same rule as logos.

performance and size

Work on the perf branch: faster workflow execution, queue dispatch, API authentication and database queries, smaller client bundles, and removal of code and dependencies that no longer earn their place. It also fixes two engine bugs (long delays were skipped; whole-string expression functions ran twice) and a few database privilege problems.

Measured on a local stack (relative numbers; details and caveats in docs/perf/results.md):

measurebeforeafter
API p50 (/workflows, /tasks, /me, MCP)79–102 ms3–8 ms
Tasks list as a member (RLS, 70k rows)1 546 ms38 ms
200 queued jobs with one kick598 s (pg_cron, 20 jobs/min)0.9 s
500 data-table inserts with a trigger workflow5.4 s, 3 006 round trips2.3 s, 1 008
Decision table ×200 / call_reference ×100466 / 241 ms91 / 78 ms
Client JS (gzip)7.6 MB (2.0)5.6 MB (1.5)
node_modules / Docker image936 / 92 MB866 (786 without optional drivers) / 88.5 MB

Apply the migrations (below and in docs/migrations.md) together with the deploy; 20261028000000 must be in place before the new app serves report pages.

Breaking changes

  • /api/v1/workflows/author is removed. Use:
    • POST /api/v1/workflows to create a workflow. It takes the same body as POST /workflows/author did (name, trigger_type, nodes, edges, user_variables, description, trigger_config, swimlanes, sections, tags, is_active, and publish / publish_notes to also publish version 1), returns 201 with { workflow_id, name, published }, and needs workflows.edit or workflows.create (plus workflows.activate with publish: true).
    • PATCH /api/v1/workflows/{id} to update one (it replaces PUT /workflows/author; the id moves from the workflow_id body field to the path). Same fields, all optional, plus publish / publish_notes. Requests to the old path now get 405. MCP tools are unchanged.
  • SDK / CLI. The SDK operation createWorkflow and usertasks workflows create now call POST /workflows. The SDK updateWorkflow operation is removed: use updateWorkflowDefinition (usertasks workflows update).
  • Excel is .xlsx only, dates are ISO strings. read_excel, doc_excel, report export and the agent doc tool use ExcelJS instead of SheetJS 0.18 (unmaintained, open CVEs). Only .xlsx workbooks are read (.xls, .ods and CSV files saved as workbooks fail with "Not a readable .xlsx workbook"); use read_csv for CSV. Date cells are read as ISO 8601 strings (2026-01-15, or a full timestamp when there is a time part) instead of Excel serial numbers — workflows that did arithmetic on the serial number should use the date functions (ADD_DAYS, DAY_DIFF, FORMAT_DATE, …). doc_excel now keeps most template formatting (SheetJS dropped cell styles on save).
  • Optional database drivers / Docker. Snowflake (snowflake-sdk), Oracle (oracledb) and MongoDB (mongodb) are optionalDependencies. The Docker image leaves them out unless built with --build-arg INCLUDE_OPTIONAL_DB_DRIVERS=true; without the driver the connector fails with "The Snowflake connector requires the optional package snowflake-sdk; install it with npm i snowflake-sdk …". See Optional drivers.
  • Removed routes and pages. POST /api/webhooks/dispatch and POST /api/decision-tables/execute (internal, uncalled) return 404. The old /api-keys and /webhooks pages redirect (308) to /settings/api-keys and /settings/webhooks. The legacy integration "endpoints" list and its table are gone (references and objects replaced them).
  • Retired node types. send_push and ai_action ("coming soon" placeholders) are no longer offered. Stored workflows that contain them still load and run; the engine skips them as unknown node types.

Behaviour changes

  • Task completion resumes the workflow from the queue. Completing a workflow task (POST /api/v1/tasks/{id}/action, MCP tasks_complete_workflow, /api/events/complete, the agent action tool and the UI) no longer runs the rest of the workflow inside the request. It enqueues a signed task_resume job and returns at once with workflow_resumed: true, workflow_status: "queued", run_id and resumed: "queued" (previously workflow_status was the run's status after the downstream nodes had run). Poll GET /api/v1/workflows/runs/{run_id} for the outcome. Resumes are claimed atomically: a double submit resumes the run once.
  • Delays over 30 s pause the run. delay and pause_until longer than 30 s used to return immediately (reporting the full wait). The run now goes to waiting_for_event and a delay_resume queue job continues it at the next node, variables intact, when the time is up (within the queue's dispatch latency: the next kick or pg_cron minute). Waits of 30 s or less still happen inline. Exception: inside a loop body the wait is still skipped, with a warning in the run log, because loop state is not resumable.
  • Regex safety limits. User patterns in REGEX_MATCH / REGEX_EXTRACT / REGEX_REPLACE, the condition operator matches and the Regex node are rejected when longer than 500 characters or when they nest unbounded quantifiers ((a+)+, (\d*)*, (a|b+){2,}), and the input is capped at 100 000 characters. The functions and matches treat a rejected pattern like an invalid one (false, "", or the input unchanged); the Regex node fails with a "Regex rejected: …" error.
  • Stored run output is capped. Each node's output in workflow_runs.node_results is stored up to 32 KB; a larger output is replaced by a _truncated preview (the run itself still sees the full value). execution_trace and workflow_logs keep at most 5 000 entries per run (head and tail, with a "truncated" marker). Tune with WORKFLOW_NODE_RESULT_MAX_BYTES, WORKFLOW_TRACE_MAX_ENTRIES, WORKFLOW_LOG_MAX_ENTRIES.
  • API key validation is cached. A successful validation is cached in-process for 60 s (keyed by the token's SHA-256, at most 1 000 entries, never past the key's expiry). Revoking or deleting a key, changing roles, revoking an OAuth grant or deactivating a user through directory sync invalidates the cache on the instance that handled the change; other instances (serverless or multiple containers) keep accepting the key for up to 60 s. last_used_at is written at most once a minute per key.
  • Queue dispatch. One processor invocation claims jobs in batches and runs up to WORKFLOW_CONCURRENCY (default 5) at once, so jobs of a burst start in parallel and DB-trigger enqueues no longer wait 20 per cron minute. Deployments that set only CRON_SECRET now get immediate kicks (they were rejected with 401 and fell back to pg_cron). Completed queue jobs store {status, run_id} instead of the full node results, and finished workflow_queue / agent_queue rows are purged after 7 days (QUEUE_RETENTION_DAYS). /api/cron/process-workflows only drains the queue; retention runs once a day and SLA checks every 5 minutes.
  • Run statistics. workflows.run_count is incremented atomically (concurrent runs no longer lose counts).
  • Reports aggregate in the database. The workflow report pages (hub, workflow detail, compare) use SQL RPCs instead of downloading up to 10 000 runs with their traces into the browser: totals, status counts and daily trends now cover every run in the selected period (not just the newest 10 000), p50/p95 durations are interpolated percentiles (percentile_cont), the compare page's per-node visit counts use the newest 5 000 runs per workflow, and the workflow detail heat map loads the traces of the newest 2 000 runs from a server route only when it is shown. Expect small differences from the old figures on busy workflows.
  • Workflow list. GET /api/v1/workflows and the UI list read a stored node_count instead of downloading every definition.

Security fixes

  • workflow_logs inserts through RLS were allowed for anyone (WITH CHECK (true)); now member tenants only (the engine uses the service role).
  • tag_definitions inserts need an authenticated user and their current tenant (anon and tenant-less inserts were allowed).
  • create_task_with_subtasks rejects assignees outside the caller's tenant.
  • Every SECURITY DEFINER function has a pinned search_path; queue worker RPCs (claim_jobs, release_jobs, purge_queue_history, record_workflow_run) are service-role only.
  • user_memberships reads no longer fail with 42P17 (recursive policy), which broke the org switcher for end users.

New environment variables (all optional)

variabledefaultwhat it does
WORKFLOW_CONCURRENCY5jobs one queue-processor invocation runs at once
WORKFLOW_PROCESSOR_BUDGET_MS50000how long one invocation keeps starting jobs (unstarted claims are released)
QUEUE_RETENTION_DAYS7days finished workflow_queue / agent_queue rows are kept
WORKFLOW_NODE_RESULT_MAX_BYTES32768max stored size of one node's output
WORKFLOW_TRACE_MAX_ENTRIES5000max execution_trace entries per run
WORKFLOW_LOG_MAX_ENTRIES5000max workflow_logs rows per run
INCLUDE_OPTIONAL_DB_DRIVERS (Docker build arg)falseinstall the Snowflake/Oracle/MongoDB drivers in the image

Migrations

Apply in order, after the strip-back, CLI and log-export migrations (full ordered list and the live sequence: docs/migrations.md). All are idempotent.

MigrationWhat it doesLive notes
20261025000000_record_workflow_run.sqlrecord_workflow_run RPC (atomic run_count / stats)The engine falls back to the old update until it exists: can go before or after the deploy
20261026000000_queue_dispatch.sqlclaim_jobs, release_jobs, idx_queue_claim, slim complete_job results, enqueue_workflow_batch, purge_queue_historyThe processor falls back to claim_next_job until it exists. idx_queue_claim is a small partial index; build it CONCURRENTLY first if the queue table is large
20261027000000_rls_initplan.sqlRLS policies as initplans, redundant policies dropped, wl_insert and user_memberships fixes, search_path on SECURITY DEFINER functions, create_task_with_subtasks assignee checkCatalog only (no rewrite) but takes a brief ACCESS EXCLUSIVE lock per table; lock_timeout = 5s, re-run if it times out
20261027000001_perf_indexes.sqlIndexes for task/run/log lists, assignees, trigram title search (pg_trgm); 11 duplicate indexes droppedPlain CREATE INDEX blocks writes while it builds: on production build the indexes CONCURRENTLY first (statements in docs/migrations.md), then push
20261028000000_perf_api_reports.sqlworkflows.node_count (+ trigger, backfill), tag_definitions insert policy, run-report RPCsBefore the new app version: report pages call the RPCs
20261029000000_drop_integration_endpoints.sqlDrops the legacy integration_endpoints table and integration_references.endpoint_idIrreversible; nothing in the new app reads them. Back up first if you want the rows

Development

  • scripts/bench/ is an opt-in benchmark harness (local stack only); see its README and docs/perf.
  • The supabase CLI is no longer a devDependency; scripts and docs use npx supabase (or a global install).
  • supabase/migrations_archive/ (92 pre-baseline migrations, never applied) is removed; it remains in git history.

log export and live event streaming

This release adds one normalized event stream per workspace and three ways to use it:

  • Push to Splunk HEC, Datadog Logs, OpenTelemetry (OTLP/HTTP) or a signed HTTPS webhook (log destinations).
  • Pull with GET /api/v1/events (cursor) or stream with GET /api/v1/events/stream (Server-Sent Events).
  • Tail with usertasks logs tail / usertasks logs export, or query with the MCP events_list tool.

It also fixes several database privilege problems (see Security fixes). Apply the migrations before deploying the app.

Migrations

Apply in order (all idempotent):

MigrationWhat it does
20261024000000_event_stream.sqlevent_stream outbox (service role only), normalization triggers on 14 source tables, purge_event_stream, (tenant_id, created_at) index on workflow_logs
20261024000001_observability_permission.sqlobservability.view for the system Owner and Admin roles (new and existing workspaces); events:read for the first-party CLI client
20261024000002_log_destinations.sqllog_destinations table (service role only)
20261024000003_log_shipper.sqlclaim_next_log_destination, purge marks, pg_cron job usertasks-ship-events, queue worker RPC revokes
20261024000004_enqueue_workflow_tenant_check.sqlTenant checks in enqueue_workflow and other exposed SECURITY DEFINER functions

The pg_cron job uses the existing Vault secrets app_base_url and cron_secret. Without pg_cron nothing is shipped; the pull API and CLI still work.

What's new

  • Event stream. Workflow run transitions and SLA warnings/breaches, step logs (workflow.step.log, can be turned off per workspace with settings.event_stream.include_step_logs = false), queue failures, integration calls and dead-lettered webhooks, agent runs, task lifecycle, audited changes, OAuth/API key/permission security events, retention, deletion and sync runs. Attributes are allow-listed per source: no request/response bodies or headers, prompts, outputs, credentials or full audit diffs. Retention: 72 hours by default (tenants.settings.event_stream_retention_hours, max 168).
  • New security audit events. OAuth consent granted/denied, tokens issued (every grant) and revoked, authorization-code replay and refresh-token reuse, and sampled API key authentication failures (at most one per key prefix per 5 minutes).
  • Log destinations. Settings → Log Export, /api/v1/log-destinations, MCP log_destinations_*, usertasks log-destinations … and config as code (log_destinations section; credentials are never exported). Credentials are encrypted and always masked. Delivery is at least once (dedupe on the event id) with exponential backoff capped at 5 minutes. A destination becomes failing after 5 consecutive client errors and lagging_data_lost if it falls further behind than retention; owners and admins are notified once per transition. "Send test event" delivers one system.test event through the real send path.
  • Pull API and SSE. GET /api/v1/events and GET /api/v1/events/stream (filters: category, type globs, severity, workflow_id, run_id, task_id, trace_id, since; resume with after or Last-Event-ID). On serverless hosts the stream ends at the function limit with an event: reconnect hint; the SDK and CLI reconnect without gaps. The SSE endpoint takes API keys and OAuth tokens, not browser sessions.
  • SDK / CLI. client.events.list/iterate/stream; usertasks events list, usertasks logs tail (text or NDJSON, auto-reconnect), usertasks logs export.
  • Docs. /docs/observability: event envelope, type catalogue, severity mapping, per-destination setup, webhook signature verification (Node.js and Python), SSE usage and self-hosting notes.

Permissions

  • New permission observability.view reads events and log destinations. The system Owner and Admin roles get it by default (existing workspaces are updated by migration 20261024000001). Manager, Member and Viewer do not; add it to custom roles that need it.
  • Audit and security events also need settings.view, both for reading and for a destination that exports them (no category filter, or one naming audit / security).
  • Changing destinations needs settings.edit as well.
  • OAuth scope events:read maps to observability.view.

Security fixes

  • Queue worker RPCs (claim_next_job, complete_job, fail_job, the agent and deletion job equivalents, and the GitHub/directory sync claim functions) were executable by anon and authenticated. Several are SECURITY DEFINER, so anyone with the public anon key could claim, complete or fail other workspaces' jobs. They are now service_role only (migration 20261024000003). Every app caller already used a service-role client.
  • /api/agents/process now accepts the Authorization: Bearer <CRON_SECRET> header that pg_cron sends, so the agent queue job authenticates the same way as the other cron endpoints.
  • enqueue_workflow (SECURITY DEFINER) accepted any p_tenant_id from anon and authenticated callers, so the public anon key could queue any workspace's workflow — which the service-role queue processor then ran — or attach another workspace's task to a run. Now (migration 20261024000004): the workflow and task must belong to p_tenant_id for every caller; signed-in users must be members of that workspace; anon can no longer execute it. Triggers, service_role and direct database sessions are unchanged.
  • In the same migration: reassign_task and escalate_task (no tenant check, executable by anon) now require membership of the task's workspace; get_task_tree only returns tasks the caller is a member, assignee or assigner of; check_object_permission no longer interpolates an arbitrary permission name into dynamic SQL.

Behaviour changes

  • Calling enqueue_workflow directly with a workflow from another workspace, or a p_task_id from another workspace, now fails with workflow_not_found / task_not_found — including through POST /api/v1/workflows/start and MCP workflows_start with async: true and a foreign task_id.
  • cleanup-logs now ages integration_call_logs by started_at (the old created_at filter never matched) and purges event_stream.
  • workflow_runs.trace_id is set when a run is created, so a run's integration calls and agent runs share its trace id.

Self-hosting

  • ALLOW_PRIVATE_EGRESS=true allows destinations on private networks and plain http.
  • NODE_EXTRA_CA_CERTS trusts a private CA (Splunk verify_tls=false is not supported).
  • LOG_EXPORT_DATADOG_URL replaces the Datadog intake URL for every Datadog destination (proxy, Observability Pipelines worker or test intake).

CLI, complete REST API and MCP permission changes

This release adds the usertasks CLI (@usertasks/cli) and TypeScript SDK (@usertasks/sdk), extends /api/v1 to cover everything the UI can configure, and moves REST, MCP and most server actions onto one shared service layer (lib/services/*). The API is described by the OpenAPI 3.1 document at GET /api/v1/openapi.json.

Most changes only add things. The ones below can change what an existing API key, OAuth token or MCP client is allowed to do. Check them before you upgrade.

Breaking: MCP read tools now need a view permission

Until now, MCP tools that only read data had no permission check. Tenant scoping was the only limit. Read tools now need the same *.view permission as the matching REST endpoint or UI page. A key or token without that permission gets Forbidden: … lacks the '<permission>' permission.

Read toolsPermission
tasks_list, tasks_get, tasks_priority_summarytasks.view_own
workflows_list, workflows_get, workflows_catalog, workflows_preflight, workflows_resolve_dependencies, workflows_versions_listworkflows.view
workflows_runs_list, workflows_run_get, trace_getworkflows.view_runs
agents_list, agents_get, agents_runs_list, agent_runs_list, agent_runs_getagents.view
data_tables_list, data_tables_get, data_rows_query, data_rows_searchdata.view
reports_* reads (list, get, query, preview, export, sources_list, source_get)reports.view
integrations_list/get, references_list/get, objects_list/get, connectors_list/get, integration_calls_list/getintegrations.view
decision_tables_list/get/export/test, email_templates_list/get/previewworkflows.view
workflow_activities_list/get/exportextensibility.view
users_list, roles_list, team_members_list, teams_listteam.view_members
audit_log_query (governance log)team.change_roles
object_audit_log_querysettings.view
invitations_listteam.invite
api_keys_listsettings.manage_api_keys
retention_policies_list, retention_policies_get, retention_policies_list_assetssettings.view

Legacy tenant keys, which have no stored permission list, still resolve to every permission, so they are not affected. Least-privilege keys, user keys and OAuth tokens are affected: their reads are now limited to what their permissions allow. Any tool that is missing from the permission maps is now refused, so every new tool must be given a permission. __tests__/mcp-read-gating.test.ts lists the expected permission for each tool.

Breaking: OData needs data permissions (phase 1)

/api/odata now checks permissions as well as the table allowlists. Reads need data.view, writes need data.manage_rows, and report collections need reports.view.

Other MCP behaviour changes

Phases 4 to 8: tools moved onto the service layer

These tools keep the same names, arguments and response shapes:

  • Integration, reference, object and connector tools.
    • Secrets come back masked as __SECRET__. If you send __SECRET__ back on an update, the stored value is kept.
    • Outbound URLs go through the egress guard.
    • Each change writes one detailed audit row from the service. Before, the MCP layer wrote a generic MCP: <tool> row.
  • *`workflow_activities_`.** Sources and manifests are validated, and a duplicate key returns an error. Deletes are queued, and changes are captured for GitHub sync.
  • *New `email_templates_` tools** (list, get, create, update, delete, preview).
    • HTML is sanitised.
    • The workspace default template cannot be deleted.
    • These tools use the workflows.* permissions.
  • decision_tables_export / decision_tables_import. Imports are audited and captured for GitHub sync.
  • *`data_tables_ and data_rows_`* (data_rows_search excepted).
    • Cells are checked against the table's columns and stored under canonical column keys.
    • ID and Date Created are filled in automatically.
    • Creates and updates now fire data_table_event workflows.
    • Deletes are queued.
    • Table resource scopes are enforced.
  • workflows_delete. The delete is queued, and the workflow's versions, runs and spawned tasks are deleted with it.
  • workflows_toggle and workflows_versions_list. These run through the workflow service and are audited there.

Phase 9: governance and API keys

  • Team tools now use the team UI's permissions.
    • teams_create, teams_update and teams_add_member need team.invite.
    • teams_delete and teams_remove_member need team.remove.
    • Before, all of them needed team.change_roles.
    • Teams, users and parent teams must all belong to the workspace.
  • roles_manage.
    • System roles cannot be renamed or deleted.
    • Unknown permission keys are dropped.
    • Assigning a role checks that both the role and the user belong to the workspace.
  • Invitations.
    • invitations_list and invitations_send no longer return the acceptance token.
    • invitations_send now sends the invitation email. It checks the email address and the role (owner, admin, manager, member or viewer) and refuses existing members and duplicate invitations.
    • New tool: invitations_resend.
  • api_keys_create is hardened. See "API keys" below. A request for permissions the caller lacks is now refused. Before, those permissions were silently dropped.

Surface parity: workflow authoring, decision tables, reports

REST, MCP and the CLI now cover the same operations for every configurable resource (matrix: docs/parity.md, enforced by __tests__/surface-parity.test.ts).

  • Publishing needs workflows.activate everywhere. POST /workflows/author and PUT /workflows/author with publish: true, and workflows_update with publish: true, now need workflows.activate (as the editor and workflows_publish always did). Changing is_active through an update also needs workflows.activate. Before, workflows.edit was enough.
  • Workflow authoring is one service. /workflows/author, workflows_create, workflows_update and workflows_publish share lib/services/workflows: they are audited and queued for GitHub sync, every edge must point at an existing node (before, REST checked this only when nodes and edges were both sent), node ids must be unique, the middleware rules apply to REST too, and the published snapshot includes sections. Updates report changed and only the fields that differ in updated_fields; an update that changes nothing no longer marks the workflow as having unpublished changes. Nodes, edges and variables keep properties the schema doesn't name (they used to be stripped by REST), and REST create/update accept description, trigger_config and tags.
  • workflows_update accepts definition (the workflows_get_definition payload, or workflows_get output) and publish / publish_notes.
  • Decision tables (/decision-tables*, decision_tables_*) share lib/services/decision-tables: create/update/delete are audited and synced, delete goes through the deletion queue (as in the UI), missing field / rule / condition ids are generated on REST too, and condition values are stored as strings. REST create accepts workflows.create or workflows.edit.
  • integrations_get now returns the config with credentials masked as __SECRET__ (as GET /integrations/{id} does), so it can be edited and sent back to integrations_update.
  • Data tables: an update's columns may include the system columns (id, date_created) a GET returns; they are ignored. Before, they were rejected.
  • reports_import normalises the config as report create does, and is audited.
  • Every update accepts its GET response as the body ({ <item>: {...} } is unwrapped, read-only fields are ignored, null for a non-nullable field means unchanged). Agents, reports and retention policies accept PATCH as well as PUT.
  • New MCP tools: workflows_get_definition, workflows_version_get, webhooks_list|get|create|update|delete|rotate, objects_update, references_bulk_delete, connectors_list_installed, data_tables_delete, data_rows_get, data_rows_bulk_delete, api_keys_delete, roles_get, teams_get. Webhook reads need settings.manage_webhooks.

API keys: new limits on creation

These rules apply to REST, MCP and the settings UI:

  • Permissions. A new key's permissions must be a subset of the caller's. If you omit them, the key gets the caller's permissions. A caller with every permission still gets a legacy full key. Keys created in the UI by users without every permission now get exactly that user's permissions. Before, they were full-access keys.
  • User keys. A user key's permissions must also be within the bound user's role permissions.
  • Resource scopes. If the caller is limited to certain workflows or data tables, the new key's workflow_ids / table_ids must be within those limits. If you omit them, the key gets the caller's limits.
  • User callers. A caller acting as a user (a user key or an OAuth user token) cannot create tenant-wide keys. It can only create user keys for itself.
  • OAuth callers need the first-party keys:write scope to create, revoke or delete keys. Without it they get 403 with code insufficient_scope. Keys created with an OAuth token must set expires_in_days (at most 90).
    • When this happens, the CLI suggests the fix, for example: re-run: usertasks login --scopes <current>,keys:write.
  • Rate limits. Each caller can create 10 keys per hour, and each tenant 50 per hour. Over the limit, the request fails with 429 rate_limited.
  • Audit. Every create, revoke and delete is audited.
  • Key format. New keys look like ut_<64 hex>. The key is returned once. Only a bcrypt hash and an 8-character prefix are stored.

Webhooks

/api/webhooks/create-key, the endpoint the workflow editor uses, now:

  • takes the tenant from the signed-in session instead of the request body;
  • checks that the workflow belongs to that tenant;
  • requires settings.manage_webhooks;
  • generates the secret on the server.

Before, any signed-in user could create or rotate a webhook for any tenant. Editors without settings.manage_webhooks, such as the seeded Manager role, can no longer create webhook endpoints from the workflow editor. The inbound endpoint /api/webhooks/inbound works as before.

New REST endpoints (/api/v1)

AreaEndpoints
IdentityGET /me, GET /openapi.json
IntegrationsGET,POST /integrations; GET,PATCH,DELETE /integrations/{id}; POST /integrations/{id}/test; POST /integrations/import-openapi
ReferencesGET,POST /integrations/{id}/references; GET,PATCH,DELETE /references/{id}; POST /references/bulk-delete; POST /references/{id}/execute
ObjectsGET,POST /objects; GET,PATCH,DELETE /objects/{id}
ConnectorsGET /connectors/catalog; GET,POST /connectors; GET,PATCH,DELETE /connectors/{id}; POST /connectors/{id}/{test,enable,disable,regenerate-references}
Email templatesGET,POST /email-templates; GET,PATCH,DELETE /email-templates/{id}; POST /email-templates/{id}/preview
Workflow activitiesGET,POST /workflow-activities; GET,PATCH,DELETE /workflow-activities/{id}; GET /workflow-activities/{id}/export; POST /workflow-activities/import
Decision tablesPATCH /decision-tables/{id}; GET /decision-tables/{id}/export; POST /decision-tables/import
ReportsGET /reports/{id}/export; POST /reports/import; PATCH /reports/{id}
Agents, retentionPATCH /agents/{id}, PATCH /retention-policies/{id} (same as PUT)
Data tablesGET,POST /data-tables; GET,PATCH,DELETE /data-tables/{id}; GET,POST /data-tables/{id}/rows; GET,PATCH,DELETE /data-tables/{id}/rows/{rowId}; POST /data-tables/{id}/rows/bulk-delete
WorkflowsGET /workflows/{id}/definition; PATCH,DELETE /workflows/{id}; POST /workflows/{id}/publish; POST /workflows/{id}/activate, /deactivate; GET /workflows/{id}/versions[/{versionId}]
TasksPATCH /tasks/{id}
Roles & usersGET,POST /roles; GET,PATCH,DELETE /roles/{id}; GET /users; PUT,DELETE /users/{id}/roles/{roleId}
TeamsGET,POST /teams; GET,PATCH,DELETE /teams/{id}; PUT,DELETE /teams/{id}/members/{userId}
InvitationsGET,POST /invitations; DELETE /invitations/{id}; POST /invitations/{id}/resend
Audit logGET /audit-log (object_type, object_id, actor_id, action, from, to, limit/offset)
API keysGET,POST /api-keys; POST /api-keys/{id}/revoke; DELETE /api-keys/{id}
WebhooksGET,POST /workflows/{id}/webhooks; GET,PATCH,DELETE /webhooks/{id}; POST /webhooks/{id}/rotate (the secret is shown once)
Config as codeGET /config/export; POST /config/apply (dry_run, adopt_by_name, source_label)

Error responses keep the { success: false, error } shape. They can now also include a machine-readable code (validation_error, forbidden, insufficient_scope, not_found, conflict, rate_limited, …) and details.