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
.utpkganywhere 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.installin) 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}/uninstallgainscancel_runsand its dry run returnsactive_runs,missing_permissionsandblockers. - MCP:
packages_history,packages_history_get;packages_uninstallgainscancel_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 tablepackage_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 installwas 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 withnpm 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 bynext.config.tsand by the error messages. A test checks it againstpackage.json, the stubs and theDockerfilebuild args. - Docker Compose passes
INCLUDE_STORAGE_SDKSandINCLUDE_PROVIDER_SDKSto the image build, as well asINCLUDE_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 againstDATABASE_SSL_CA_FILEor the RDS CA bundle now shipped in the image (certs/rds-global-bundle.pem). Applies to every connection the app, the worker andusertasks-admin db migrateopen.DATABASE_AWS_REGIONoverrides 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).PutLogEventswithin 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 asecret://tenants/<workspace id>/…reference), or the instance's role withLOG_EXPORT_ALLOW_INSTANCE_IDENTITY=true.LOG_EXPORT_AWS_ENDPOINT_URLfor VPC endpoints and LocalStack. Migration20261111000000widens the destination type check. - Amazon Cognito OIDC preset (
"preset": "cognito"): issuer fromuserPoolId, hosted UI / managed logindomain,cognito:groupsas the default groups claim forroleMapping. - Cognito-verified tick:
SCHEDULER_COGNITO_USER_POOL_ID,SCHEDULER_COGNITO_ALLOWED_CLIENTSandSCHEDULER_COGNITO_SCOPEaccept access tokens from an EventBridge API destination whose connection uses OAuth client credentials.node worker.cjs --onceno longer warns underSCHEDULER_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 withterraform 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
awsflag on every provider,usertasks-admin providers list --aws, an AWS badge on Settings → Infrastructure, and a doctorauthcheck for token database auth (therds_iamgrant and TLS). - Docs: AWS setup, the AWS topology now native, and the services pages.
Notes
- The new optional packages are
@aws-sdk/rds-signerand@aws-sdk/credential-providers(dropped withINCLUDE_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(), migration20261109500000_instance_housekeeping.sql): deletescron.job_run_detailsrows older than 1 day,net._http_responserows 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 withlock_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 inapp_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-housekeepingevery 15 minutes (runs when the interval is due) andusertasks-cron-housekeeping, now the daily forced pass with a larger budget. Worker / external: the job registry'shousekeeping(every 15 min) andhousekeeping-daily(03:30 UTC), which call the same function;/api/cron/housekeepingruns a pass on demand. usertasks-admin housekeeping show | set <setting> <value|default> | run [--dry-run];runreports rows deleted per table.statusprints a housekeeping line, andstatus/doctorwarn about a late or disabled pass and about large or bloated purged tables (with the hint that a Supabase-owned table needsVACUUM FULLby 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-housekeepingkeeps its name and 03:30 UTC slot but now runsapp_cron.run_housekeeping('pg_cron-daily', …); the oldapp_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_croninstall 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.provideracceptsldap,google,scim;directory_sync_runs.provideracceptsldap,google, and runs recordmode(full/delta);scim_tokens,scim_resources,scim_group_members(RLS on, service role only);cron_tick_probe()counts enabled LDAP and Google connectors for thedirectory-syncjob (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]]; CLIusertasks directory-sync list|get|connect|update|disconnect|test|sync|runsandusertasks scim-tokens list|create|delete|rotate; MCPdirectory_sync_*andscim_tokens_*(reads needsettings.view, changessettings.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:
uSNChangedis per DC. - SCIM
DELETEandactive: falseremove 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])andFILTER_WHERE(list, field, value);PLUCKaccepts 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 MCPreferences_getreturnresponse_schema(derived from outputs when none is stored); create/update acceptresponse_schema(nullclears it);POST /api/v1/references/{id}/executeand MCPintegrations_execute_referencetakecapture_schema: trueto fold the response into the model (usertasks references execute <id> --set capture_schema:=true).
Unchanged
- Existing
output_mappingsand 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
.utpkgfile (ZIP:manifest.json, the config-as-code layout underassets/, 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.installprompts for missing values on a terminal (secrets without echo); in scripts use--set <requirement>.<field>=value,-f values.yamland${env:VAR}, or--keep-incomplete.inspectverifies a file offline. - Permissions:
packages.view,packages.manage,packages.install(Owner, Admin and Manager by default). OAuth scopespackages:read/packages:write;config:readincludespackages.viewandconfig:writeall 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, MCPconfig_export/config_applyandusertasks config export --ids … --with-dependencies --include-rows. Data-table rows travel as thetable_rowssection (data-rows/folder) and apply withrows_modemerge / replace / skip. Seedocs/export-coverage.md.
Fixed
notification_template_id(user-task notification email templates) and retention-triggerpolicy_idsare 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 andusertasks-admin rollback <switch-id>.historylists the switch log;instance-adminsmanages who sees the Infrastructure page.- Settings → Infrastructure (read-only, instance admins only —
INSTANCE_ADMIN_EMAILSor theinstance.admin_emailssetting, 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: allowsinstance.<option>keys ininstance_settingsand addsinstance_provider_switches(the switch log; service role only, likeinstance_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.cjsnow also loads the optional storage / email / secrets SDKs from the app'snode_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 packagepackages/admin;npm run admin -- …in a checkout,node admin.cjs …in the Docker image, which now shipssupabase/migrations/). Applies the same migration files assupabase 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'ssupabase_migrations.schema_migrationsformat. See Infrastructure: Database.20261014000000_compat_bootstrap.sql: on non-Supabase PostgreSQL it creates the API roles, theauth.uid()/role()/jwt()/email()helpers overrequest.jwt.claims, a minimalauth.usersand theextensionsschema. 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 realtimeLISTENconnection, the scheduler worker andusertasks-admin.- Managed PostgreSQL without
BYPASSRLS: whenservice_rolecan't be givenBYPASSRLS, 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(plusSameSite=Lax, andSecureon 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 atauth.users(users.id,user_roles.user_id,agents.created_by,reports.created_by,workspace_themes.updated_by) now point atpublic.users(or are dropped forusers.id); deleting a Supabase auth user still deletes the profile (a trigger replaces the cascade). The personal-workspace provisioning theon_auth_user_createdtrigger 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, migration20261106100000_builtin_auth.sql), bcrypt (Supabase-compatible), invitation-only sign-up by default, confirmation and reset emails throughEMAIL_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-userscopies Supabase Auth users (ids, passwords, confirmation, Google / Azure identities) into built-in auth;npm run auth -- redirect-urisprints 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
HttpOnlyon 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(GraphsendMail, client secret or managed identity),gmail(Gmail API with domain-wide delegation) andconsole(log +.emlfiles). See Infrastructure and providers.EMAIL_REPLY_TO: default Reply-To for app email.SECRETS_PROVIDER:env(now also<VAR>_FILEand a secrets directory),db(encrypted instance settings),supabase_vault,hashicorp_vault(also OpenBao; token, AppRole and Kubernetes auth),azure_key_vault,gcp_secret_managerandaws_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_KEYfrom the secret store:ENCRYPTION_KEY=secret://…, or the store'sencryption_keywhen 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 nowsupabase_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, ornode worker.cjsin the Docker image (--oncefor 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=falsekeeps 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|gcswith options as environment variables or encryptedstorage.<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/keyreferences. With a non-default provider, the database stores references for logos, theme assets and icons; data-table and workflow file values keep a usableurland gainref.- 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/filesURLs 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 PostgresLISTEN/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.sqladds statement-level NOTIFY triggers on the realtime tables. They stay idle unless an SSE listener is active, and thesupabase_realtimepublication is unchanged. - New variables:
REALTIME_DATABASE_URL(session-mode connection for LISTEN; falls back toDATABASE_URL, thenSUPABASE_DB_URL, then to polling the tables every 2 s).
Changed
- Every live-updating hook now subscribes through one
useRealtimehook 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'tsupabase); inssemode 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,optionsinuser_variables). - REST:
GET /api/v1/workflows/{id}/inputsreturns 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>andusertasks 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, MCPworkflows_startand the generatedworkflows startvalidatevariablesagainst the declared inputs: a missing required input or a value of the wrong type is a400(validation_error) with onedetailsentry per field, and nothing starts. Values are converted to the input type ("42"→42for a number input,"true"→truefor 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 newwarningsfield. Workflows without declared inputs behave as before.- The start response also carries
workflow_idandpublished_version_id; async starts still answer202withjob_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, callsPOST /api/cron/tickevery minute. The app works out which jobs are due in that minute (UTC) and runs them in-process, up toCRON_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 inlib/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 astimeoutand finishes in the background (all jobs claim their work, so nothing runs twice). - History.
app_cron.tick_runskeeps each job's last start, finish, status, duration and error. - Housekeeping.
usertasks-cron-housekeeping(daily 03:30 UTC, pure SQL, no HTTP) deletescron.job_run_detailsolder than 2 days andnet._http_responseolder than 1 hour. The migration also purges the existing backlog once. - Endpoints unchanged.
/api/cron/process-schedules,/api/workflows/process,/api/cron/ship-eventsand the rest still work on their own for external schedulers, in-app kicks and manual runs. Their logic moved tolib/jobs/*so the tick and the endpoints share it./api/cron/process-agentsnow runs the agent queue directly instead of forwarding to/api/agents/processover 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
- Deploy the app version with
/api/cron/tick. - Apply
supabase/migrations/20261103100000_cron_consolidation.sql(npx supabase db push, or run it in the SQL editor). It is idempotent. - Check:
select jobname, schedule from cron.job where jobname like 'usertasks-%';shows two rows, and after a minuteselect * 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,tdandimg. Links may behttp(s),mailtoor relative and always open in a new tab withrel="noopener noreferrer". Images must use an absolutehttpsURL. Everything else is removed on save: scripts, styles,style/class/idandon*attributes, iframes, forms, SVG and MathML, andjavascript:ordata: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}andPATCH /tasks/{id}) keepdescriptionas stored and adddescription_text, a plain-text version. Add?strip_html=trueto the threeGETendpoints to get plain text indescriptionitself. - MCP:
tasks_getreturnsdescription_textand takesstrip_html: true.tasks_createandtasks_updateaccept HTML. - CLI:
usertasks tasks list | assigned | getsendstrip_html=true, and everytaskscommand converts any HTML description in its output to plain text. Pass--htmlto see the stored HTML. - Workflows: a User Task
task_descriptionmay 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_descriptionis now the plain-text version (for notification text bodies). The newuser_task_description_htmlholds 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 andupdate_tasknodes. 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 (<).description_textand 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) andPOST /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_runsupported). - CLI:
usertasks workflows instances <id>andusertasks 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.migratedfor both runs (attributesmigrated_to_run_id/migrated_from_run_id,directionout/in). - Permission:
workflows.manage_runs(“Migrate live runs”). Owner, Admin and Manager have it; custom roles opt in. The OAuthworkflows:writescope includes it.
Changed
- New run status
migrated(terminal). Status filters, badges and reports know it. GET /workflows/{id}/versionsreturnsactive_instancesper version;GET /workflows/runsreturnsversion_id,migrated_from_run_idandmigrated_to_run_idand filters byversion_id(MCPworkflows_runs_listtoo).- 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 withoutsettings.editsee the designer read-only./settings/brand-themeredirects to/settings/theme. - Fonts: pick from the curated Google Fonts list or the system stacks, or upload a
.woff2file (at most 1 MB). Uploads go to the newbrand-fontsstorage 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
logosfolder. - REST:
GET/PUT /api/v1/workspace-theme(PUTtakes the whole theme plus an optionalexpected_version; a mismatch returns 409),POST /workspace-theme/apply-preset,GET /workspace-theme/presetsandPOST /workspace-theme/fonts(multipartfile, or JSON withcontent_base64). - MCP:
workspace_theme_get,workspace_theme_presets_list,workspace_theme_update,workspace_theme_apply_presetandworkspace_theme_font_upload. - CLI:
usertasks workspace-theme get | update | presets | apply-preset <preset_id> | upload-font. A file saved withget -o json > theme.jsoncan be sent back withupdate -f theme.json. - Config as code: the singleton section
workspace_themeis stored asworkspace-theme.jsonat 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 exportandGET /config/exportnow includeworkspace-theme.jsonwhen notypes/idsfilter 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 needssettings.edit. A dry run lists that requirement undermissing_permissions.- When a theme is applied in another workspace or environment:
- Uploaded fonts are matched by file name in the target workspace's
brand-fontsfolder. 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.
- Uploaded fonts are matched by file name in the target workspace's
- GitHub sync push and deploy do not carry the theme yet. Only
config export/config applydo. - 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):
| measure | before | after |
|---|---|---|
API p50 (/workflows, /tasks, /me, MCP) | 79–102 ms | 3–8 ms |
| Tasks list as a member (RLS, 70k rows) | 1 546 ms | 38 ms |
| 200 queued jobs with one kick | 598 s (pg_cron, 20 jobs/min) | 0.9 s |
| 500 data-table inserts with a trigger workflow | 5.4 s, 3 006 round trips | 2.3 s, 1 008 |
| Decision table ×200 / call_reference ×100 | 466 / 241 ms | 91 / 78 ms |
| Client JS (gzip) | 7.6 MB (2.0) | 5.6 MB (1.5) |
node_modules / Docker image | 936 / 92 MB | 866 (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/authoris removed. Use:POST /api/v1/workflowsto create a workflow. It takes the same body asPOST /workflows/authordid (name,trigger_type,nodes,edges,user_variables,description,trigger_config,swimlanes,sections,tags,is_active, andpublish/publish_notesto also publish version 1), returns201with{ workflow_id, name, published }, and needsworkflows.editorworkflows.create(plusworkflows.activatewithpublish: true).PATCH /api/v1/workflows/{id}to update one (it replacesPUT /workflows/author; the id moves from theworkflow_idbody field to the path). Same fields, all optional, pluspublish/publish_notes. Requests to the old path now get405. MCP tools are unchanged.
- SDK / CLI. The SDK operation
createWorkflowandusertasks workflows createnow callPOST /workflows. The SDKupdateWorkflowoperation is removed: useupdateWorkflowDefinition(usertasks workflows update). - Excel is
.xlsxonly, 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.xlsxworkbooks are read (.xls,.odsand CSV files saved as workbooks fail with "Not a readable .xlsx workbook"); useread_csvfor 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_excelnow keeps most template formatting (SheetJS dropped cell styles on save). - Optional database drivers / Docker. Snowflake (
snowflake-sdk), Oracle (oracledb) and MongoDB (mongodb) areoptionalDependencies. 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 withnpm i snowflake-sdk…". See Optional drivers. - Removed routes and pages.
POST /api/webhooks/dispatchandPOST /api/decision-tables/execute(internal, uncalled) return 404. The old/api-keysand/webhookspages redirect (308) to/settings/api-keysand/settings/webhooks. The legacy integration "endpoints" list and its table are gone (references and objects replaced them). - Retired node types.
send_pushandai_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, MCPtasks_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 signedtask_resumejob and returns at once withworkflow_resumed: true,workflow_status: "queued",run_idandresumed: "queued"(previouslyworkflow_statuswas the run's status after the downstream nodes had run). PollGET /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.
delayandpause_untillonger than 30 s used to return immediately (reporting the full wait). The run now goes towaiting_for_eventand adelay_resumequeue 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 operatormatchesand 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 andmatchestreat 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_resultsis stored up to 32 KB; a larger output is replaced by a_truncatedpreview (the run itself still sees the full value).execution_traceandworkflow_logskeep at most 5 000 entries per run (head and tail, with a "truncated" marker). Tune withWORKFLOW_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_atis 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 onlyCRON_SECRETnow 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 finishedworkflow_queue/agent_queuerows are purged after 7 days (QUEUE_RETENTION_DAYS)./api/cron/process-workflowsonly drains the queue; retention runs once a day and SLA checks every 5 minutes. - Run statistics.
workflows.run_countis 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/workflowsand the UI list read a storednode_countinstead of downloading every definition.
Security fixes
workflow_logsinserts through RLS were allowed for anyone (WITH CHECK (true)); now member tenants only (the engine uses the service role).tag_definitionsinserts need an authenticated user and their current tenant (anon and tenant-less inserts were allowed).create_task_with_subtasksrejects assignees outside the caller's tenant.- Every
SECURITY DEFINERfunction has a pinnedsearch_path; queue worker RPCs (claim_jobs,release_jobs,purge_queue_history,record_workflow_run) are service-role only. user_membershipsreads no longer fail with 42P17 (recursive policy), which broke the org switcher for end users.
New environment variables (all optional)
| variable | default | what it does |
|---|---|---|
WORKFLOW_CONCURRENCY | 5 | jobs one queue-processor invocation runs at once |
WORKFLOW_PROCESSOR_BUDGET_MS | 50000 | how long one invocation keeps starting jobs (unstarted claims are released) |
QUEUE_RETENTION_DAYS | 7 | days finished workflow_queue / agent_queue rows are kept |
WORKFLOW_NODE_RESULT_MAX_BYTES | 32768 | max stored size of one node's output |
WORKFLOW_TRACE_MAX_ENTRIES | 5000 | max execution_trace entries per run |
WORKFLOW_LOG_MAX_ENTRIES | 5000 | max workflow_logs rows per run |
INCLUDE_OPTIONAL_DB_DRIVERS (Docker build arg) | false | install 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.
| Migration | What it does | Live notes |
|---|---|---|
20261025000000_record_workflow_run.sql | record_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.sql | claim_jobs, release_jobs, idx_queue_claim, slim complete_job results, enqueue_workflow_batch, purge_queue_history | The 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.sql | RLS policies as initplans, redundant policies dropped, wl_insert and user_memberships fixes, search_path on SECURITY DEFINER functions, create_task_with_subtasks assignee check | Catalog only (no rewrite) but takes a brief ACCESS EXCLUSIVE lock per table; lock_timeout = 5s, re-run if it times out |
20261027000001_perf_indexes.sql | Indexes for task/run/log lists, assignees, trigram title search (pg_trgm); 11 duplicate indexes dropped | Plain 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.sql | workflows.node_count (+ trigger, backfill), tag_definitions insert policy, run-report RPCs | Before the new app version: report pages call the RPCs |
20261029000000_drop_integration_endpoints.sql | Drops the legacy integration_endpoints table and integration_references.endpoint_id | Irreversible; 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
supabaseCLI is no longer a devDependency; scripts and docs usenpx 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 withGET /api/v1/events/stream(Server-Sent Events). - Tail with
usertasks logs tail/usertasks logs export, or query with the MCPevents_listtool.
It also fixes several database privilege problems (see Security fixes). Apply the migrations before deploying the app.
Migrations
Apply in order (all idempotent):
| Migration | What it does |
|---|---|
20261024000000_event_stream.sql | event_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.sql | observability.view for the system Owner and Admin roles (new and existing workspaces); events:read for the first-party CLI client |
20261024000002_log_destinations.sql | log_destinations table (service role only) |
20261024000003_log_shipper.sql | claim_next_log_destination, purge marks, pg_cron job usertasks-ship-events, queue worker RPC revokes |
20261024000004_enqueue_workflow_tenant_check.sql | Tenant 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 withsettings.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, MCPlog_destinations_*,usertasks log-destinations …and config as code (log_destinationssection; credentials are never exported). Credentials are encrypted and always masked. Delivery is at least once (dedupe on the eventid) with exponential backoff capped at 5 minutes. A destination becomesfailingafter 5 consecutive client errors andlagging_data_lostif it falls further behind than retention; owners and admins are notified once per transition. "Send test event" delivers onesystem.testevent through the real send path. - Pull API and SSE.
GET /api/v1/eventsandGET /api/v1/events/stream(filters:category,typeglobs,severity,workflow_id,run_id,task_id,trace_id,since; resume withafterorLast-Event-ID). On serverless hosts the stream ends at the function limit with anevent: reconnecthint; 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.viewreads events and log destinations. The system Owner and Admin roles get it by default (existing workspaces are updated by migration20261024000001). 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 namingaudit/security). - Changing destinations needs
settings.editas well. - OAuth scope
events:readmaps toobservability.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 byanonandauthenticated. Several areSECURITY DEFINER, so anyone with the public anon key could claim, complete or fail other workspaces' jobs. They are nowservice_roleonly (migration20261024000003). Every app caller already used a service-role client. /api/agents/processnow accepts theAuthorization: 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 anyp_tenant_idfromanonandauthenticatedcallers, 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 (migration20261024000004): the workflow and task must belong top_tenant_idfor every caller; signed-in users must be members of that workspace;anoncan no longer execute it. Triggers,service_roleand direct database sessions are unchanged.- In the same migration:
reassign_taskandescalate_task(no tenant check, executable byanon) now require membership of the task's workspace;get_task_treeonly returns tasks the caller is a member, assignee or assigner of;check_object_permissionno longer interpolates an arbitrary permission name into dynamic SQL.
Behaviour changes
- Calling
enqueue_workflowdirectly with a workflow from another workspace, or ap_task_idfrom another workspace, now fails withworkflow_not_found/task_not_found— including throughPOST /api/v1/workflows/startand MCPworkflows_startwithasync: trueand a foreigntask_id. cleanup-logsnow agesintegration_call_logsbystarted_at(the oldcreated_atfilter never matched) and purgesevent_stream.workflow_runs.trace_idis set when a run is created, so a run's integration calls and agent runs share its trace id.
Self-hosting
ALLOW_PRIVATE_EGRESS=trueallows destinations on private networks and plain http.NODE_EXTRA_CA_CERTStrusts a private CA (Splunkverify_tls=falseis not supported).LOG_EXPORT_DATADOG_URLreplaces 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 tools | Permission |
|---|---|
tasks_list, tasks_get, tasks_priority_summary | tasks.view_own |
workflows_list, workflows_get, workflows_catalog, workflows_preflight, workflows_resolve_dependencies, workflows_versions_list | workflows.view |
workflows_runs_list, workflows_run_get, trace_get | workflows.view_runs |
agents_list, agents_get, agents_runs_list, agent_runs_list, agent_runs_get | agents.view |
data_tables_list, data_tables_get, data_rows_query, data_rows_search | data.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/get | integrations.view |
decision_tables_list/get/export/test, email_templates_list/get/preview | workflows.view |
workflow_activities_list/get/export | extensibility.view |
users_list, roles_list, team_members_list, teams_list | team.view_members |
audit_log_query (governance log) | team.change_roles |
object_audit_log_query | settings.view |
invitations_list | team.invite |
api_keys_list | settings.manage_api_keys |
retention_policies_list, retention_policies_get, retention_policies_list_assets | settings.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.
- Secrets come back masked as
- *`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_
anddata_rows_`* (data_rows_searchexcepted).- Cells are checked against the table's columns and stored under canonical column keys.
IDandDate Createdare filled in automatically.- Creates and updates now fire
data_table_eventworkflows. - 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_toggleandworkflows_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_updateandteams_add_memberneedteam.invite.teams_deleteandteams_remove_memberneedteam.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_listandinvitations_sendno longer return the acceptance token.invitations_sendnow sends the invitation email. It checks the email address and the role (owner,admin,manager,memberorviewer) and refuses existing members and duplicate invitations.- New tool:
invitations_resend.
api_keys_createis 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.activateeverywhere.POST /workflows/authorandPUT /workflows/authorwithpublish: true, andworkflows_updatewithpublish: true, now needworkflows.activate(as the editor andworkflows_publishalways did). Changingis_activethrough an update also needsworkflows.activate. Before,workflows.editwas enough. - Workflow authoring is one service.
/workflows/author,workflows_create,workflows_updateandworkflows_publishsharelib/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 includessections. Updates reportchangedand only the fields that differ inupdated_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 acceptdescription,trigger_configandtags. workflows_updateacceptsdefinition(theworkflows_get_definitionpayload, orworkflows_getoutput) andpublish/publish_notes.- Decision tables (
/decision-tables*,decision_tables_*) sharelib/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 acceptsworkflows.createorworkflows.edit. integrations_getnow returns the config with credentials masked as__SECRET__(asGET /integrations/{id}does), so it can be edited and sent back tointegrations_update.- Data tables: an update's
columnsmay include the system columns (id,date_created) a GET returns; they are ignored. Before, they were rejected. reports_importnormalises 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,nullfor a non-nullable field means unchanged). Agents, reports and retention policies acceptPATCHas well asPUT. - 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 needsettings.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_idsmust 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:writescope to create, revoke or delete keys. Without it they get403with codeinsufficient_scope. Keys created with an OAuth token must setexpires_in_days(at most 90).- When this happens, the CLI suggests the fix, for example:
re-run: usertasks login --scopes <current>,keys:write.
- When this happens, the CLI suggests the fix, for example:
- 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)
| Area | Endpoints |
|---|---|
| Identity | GET /me, GET /openapi.json |
| Integrations | GET,POST /integrations; GET,PATCH,DELETE /integrations/{id}; POST /integrations/{id}/test; POST /integrations/import-openapi |
| References | GET,POST /integrations/{id}/references; GET,PATCH,DELETE /references/{id}; POST /references/bulk-delete; POST /references/{id}/execute |
| Objects | GET,POST /objects; GET,PATCH,DELETE /objects/{id} |
| Connectors | GET /connectors/catalog; GET,POST /connectors; GET,PATCH,DELETE /connectors/{id}; POST /connectors/{id}/{test,enable,disable,regenerate-references} |
| Email templates | GET,POST /email-templates; GET,PATCH,DELETE /email-templates/{id}; POST /email-templates/{id}/preview |
| Workflow activities | GET,POST /workflow-activities; GET,PATCH,DELETE /workflow-activities/{id}; GET /workflow-activities/{id}/export; POST /workflow-activities/import |
| Decision tables | PATCH /decision-tables/{id}; GET /decision-tables/{id}/export; POST /decision-tables/import |
| Reports | GET /reports/{id}/export; POST /reports/import; PATCH /reports/{id} |
| Agents, retention | PATCH /agents/{id}, PATCH /retention-policies/{id} (same as PUT) |
| Data tables | GET,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 |
| Workflows | GET /workflows/{id}/definition; PATCH,DELETE /workflows/{id}; POST /workflows/{id}/publish; POST /workflows/{id}/activate, /deactivate; GET /workflows/{id}/versions[/{versionId}] |
| Tasks | PATCH /tasks/{id} |
| Roles & users | GET,POST /roles; GET,PATCH,DELETE /roles/{id}; GET /users; PUT,DELETE /users/{id}/roles/{roleId} |
| Teams | GET,POST /teams; GET,PATCH,DELETE /teams/{id}; PUT,DELETE /teams/{id}/members/{userId} |
| Invitations | GET,POST /invitations; DELETE /invitations/{id}; POST /invitations/{id}/resend |
| Audit log | GET /audit-log (object_type, object_id, actor_id, action, from, to, limit/offset) |
| API keys | GET,POST /api-keys; POST /api-keys/{id}/revoke; DELETE /api-keys/{id} |
| Webhooks | GET,POST /workflows/{id}/webhooks; GET,PATCH,DELETE /webhooks/{id}; POST /webhooks/{id}/rotate (the secret is shown once) |
| Config as code | GET /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.