556 Commits
Author SHA1 Message Date
Krzysztof CzerwinskiandClaude Opus 5.5 aace9458c6 docs(platform): list what an account erasure leaves in Stripe, cost records and handed-over agents (#16794)
### Why / What / How

**Why:** we ran two account erasures end to end on dev with test
accounts (one through the hourly job, one through the CLI), then
searched every table, the buckets, FalkorDB, PostHog and Stripe test
mode for what was left. Everything the runbook says goes was gone. A few
things that stay were not in the runbook, or were described differently
from what dev showed.

**What:** `docs/engineering/account-deletion.md` only:

- **Stripe:** the saved payment methods, subscriptions, subscription
schedules and Checkout Sessions stay too. The customer keeps its email,
name, any billing address and saved cards; invoices and completed
Checkout Sessions keep the email (invoices also the name and address
they copied); the customer, subscriptions, schedules and the trial's
Checkout Session keep the user ID in their metadata.
- **`PlatformCostLog`:** besides the nulled user ID, rows keep the chat,
run, agent and expert IDs, and an AutoPilot turn's metadata keeps the
trial's ID. The chat, run, expert and trial rows are gone; the agent ID
can still name an agent that stays, and the trial ID matches the
`trial_enrollment_id` kept in Stripe.
- **Handed-over agents:** an agent that goes over keeps its organization
and team IDs even when the erasure deletes that organization, as do its
listing versions that carry one; the listing's own organization is set
to null. It still opens and runs for the accounts using it.
- **Library creator:** nothing sets `LibraryAgent.creatorId` today, when
an entry is added, so an entry added from the Marketplace reports
`creator_name` "Unknown" (to Otto and the API) before and after the
erasure; the "Deleted user" relabel only applies to entries that name
one.

**How:** what the dev run showed, written into "Agents other accounts
use" and "What it keeps, and why".

Part of SECRT-3462.

### Changes 🏗️

- `docs/engineering/account-deletion.md`: the four points above. No code
changes.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Each statement matches what the dev erasures left (database,
Stripe test mode, a run of the handed-over agent by another account)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): none

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 11:53:07 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 7d47ba528a fix(platform): catch keys and Checkouts made in the seconds after an account closes (#16765)
### Why / What / How

**Why:** closing an account promises to end its API keys, app grants and
billing, and **Keep my account** promises that what closing stopped
stays stopped. Each REST worker caches "is this account closed" for 30
seconds, and only the worker that handled the close clears its own
cache. For up to 30 seconds after a close, a token issued before it
still works on the other workers. An API key, OAuth grant, auto top-up
setting or Stripe Checkout made in that window survives the close. A key
or grant works again after Keep. A Checkout paid later subscribes a
closed account, and the erasure left that subscription running, because
it skipped the account's own Stripe customer.

**What:** each place a leftover from that window can show up now catches
it: right after the close, where keys, grants, auto top-up and Checkouts
are made, at Keep and at erasure. The auth hot path is not changed.

**How:**
- **Close** (`service.delete_account`): once `close_account` wins, the
API keys and OAuth grants are revoked again, the plan cancel (which
first expires the open Checkouts) runs again and auto top-up is switched
off again. All are best effort (bounded, logged, never raised), because
the account is already closed, and each is skipped if the owner has kept
the account by then.
- **Making things**: `POST /api/api-keys`, the OAuth authorize route,
`POST /credits/auto-top-up` and the plan, trial and top-up Checkout
paths read the closed state from the database first
(`refuse_closed_account`, uncached), before any Stripe customer, trial
seat or charge. A closed account gets the same 401 "This account is
closed." the auth dependencies already return. There is no new copy.
- **Checkout race**: after Stripe returns a new Checkout, the closed
state is read again (`expire_if_account_closed`). If the account closed
meanwhile, the Checkout is expired and the call gets 401. Otherwise the
close came after the Checkout existed, and the sweep after the close
expires it.
- **Keep** (`reopen_account`): API keys and OAuth grants made since the
close are revoked in the same transaction that deletes the request. Only
the call that reopens the account revokes anything: a second Keep, or
one too late, changes nothing. A failure rolls back and leaves the
account closed, so the owner can try again.
- **Erasure**: the Stripe step now covers the account's own customer as
well as its sole orgs' customers, in both the dry run and the apply, as
a backstop. The step is renamed `org_subscriptions` → `subscriptions` to
match. `STRIPE_API_KEY` already reaches the database manager through the
shared config.
- **Docs**: the runbook, `restore.py` and `reopen_account` now describe
what happens in the window. Sign-ins are not ended by Keep: a token
issued before the close (up to an hour) and the browser's cached session
cookie (up to 5 minutes) are refused only while the account is closed,
and work again once it is kept.

Overlap: #16567 checks that the session is still live on the routes that
create credentials. That covers the API-key part for session tokens.
This PR touches none of its files.

Out of scope: reading the closed state through a shared cache on every
request (a separate ticket), revoking stateless tokens, and refunds.

Resolves SECRT-3459.

### Changes 🏗️

- `account_deletion/service.py`: revoke, cancel and switch auto top-up
off again after the close, best effort, while the account is still
closed.
- `data/account_deletion.py`: `refuse_closed_account` (uncached, raises
401); `reopen_account` revokes keys and grants in its transaction.
- `data/subscription_checkout.py`: `expire_if_account_closed`, called
after each Checkout is made.
- `data/credit.py` (top-up and plan Checkout) and
`data/subscription_trial_checkout.py` (trial Checkout, new or resumed):
refuse a closed account first, and expire a Checkout made as it closed.
- `api_keys/routes.py`, `oauth.py` (authorize),
`billing/credits/routes.py` (auto top-up): refuse a closed account.
- `data/account_erasure/{preflight,models,subscriptions,routine}.py`:
the account's own customer is included; the step is renamed to
`subscriptions`.
- `docs/engineering/account-deletion.md`: what closing does about the
window, and erasure step 3. The proxy timeout comment's worst case is
updated (180 s still covers it).
- Tests:
- mock-only tests for each step, the reopen's revoke, and each route's
refusal;
- Checkout tests: refused first (no customer, no trial seat), and
expired when the account closes while Stripe makes it;
  - a DB route test: a key made after the close is revoked by Keep;
  - the existing Checkout tests patch the new check.

No configuration changes.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] Mock-only unit tests pass locally: account deletion service,
reopen and Keep, the erasure steps, preflight and routine, the trial,
plan and top-up Checkout paths, the API-key, OAuth authorize and auto
top-up routes
  - [x] ruff, black, isort and pyright pass on the changed files
- [ ] The DB-backed route tests pass in CI
(`account_deletion/routes_test.py`)
- [ ] On dev, with the QA account: close the account while an old token
keeps creating API keys, then Keep. Every key is revoked.
- [ ] On dev, with the Stripe test key: the `erase-account` dry run of a
closed test account with a test customer counts its own subscriptions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 10:18:37 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 a68d90ca1d fix(backend): ban records match every spelling of an address, and Keep refuses a banned account (#16764)
### Why / What / How

**Why:** a ban record (kept when a banned account is erased) should
cover the mailbox, not one spelling of it. Today it hashes the address
only trimmed and lower-cased, so `name+x@domain` or a dotted Gmail
address is a different record. And "Keep my account" let a banned
account reopen itself with an access token issued before the ban, while
the runbook and `restore.py` say a banned person can't keep the account.
Ticket: [SECRT-3461](https://linear.app/autogpt/issue/SECRT-3461).

**What:** ban records hash a canonical form of the address, the lookup
also matches records written before this change, and Keep answers 403
`account_banned` while a ban is in force.

**How:**
- `canonical_address`: trim, lower-case, drop everything from the first
`+` in the local part (every domain), and on
`gmail.com`/`googlemail.com` drop the dots and read both domains as
`gmail.com`. A part that would come out empty is left as it is.
- `keep_ban_record` writes the canonical hash. `is_banned_address` asks
for `{"in": [canonical, old]}`, where "old" is the trimmed, lower-cased
hash existing records hold. Existing rows are not rewritten.
- `restore_account` checks `is_user_banned` first and raises
`BannedAccountError` before touching anything; the route maps it to 403
with `ACCOUNT_BANNED_DETAIL`, which the frontend's request layer already
handles by signing the person out. A lapsed temporary ban is not a ban,
so that account can still be kept.

### Changes 🏗️

- `backend/data/account_erasure/ban_record.py`: `canonical_address`,
`email_hashes`; `email_hash` now hashes the canonical form; lookup
matches both hashes.
- `backend/api/features/account_deletion/restore.py`, `routes.py`: Keep
refuses a banned account (403 `account_banned`); docstrings updated.
`openapi.json` regenerated (the 403 description).
- `backend/api/conftest.py`: the new `is_user_banned` call site joins
the list the no-bans fixture patches.
- Tests: `ban_record_test.py` (+tag, Gmail dots, googlemail, an old
record still matching, a non-Gmail dot still distinct),
`restore_test.py` (banned refused with nothing changed, not banned
kept), `routes_test.py` (DB-backed: banned account refused with a token
from before the ban; a lapsed ban can keep).
- `docs/engineering/account-deletion.md`: the canonical form under "Ban
records", `email_hashes` under "Changing a ban record", and Keep
refusing a banned account.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `ban_record_test.py` and `restore_test.py` pass locally
(mock-only)
  - [x] `poetry run format` (ruff, isort, black, pyright) clean
  - [ ] `routes_test.py` (database-backed) in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 09:10:13 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 c6533779f0 fix(backend): free a crashed account erasure's lease within minutes, log CLI runs and page on stalled erasures (#16766)
### Why / What / How

**Why:** a run of `erase-account` that crashes or is killed (for example
when its pod is OOM-killed) never releases its per-account Redis lease,
and the lease lasted an hour. Until it ran out, every rerun said
"another run is erasing it" and exited 1. A CLI erasure also left no log
line, since the CLI switched INFO logging off, and nothing noticed an
erasure that started and never finished: the hourly job logs its own
failures, but it is off by default, and a failed CLI run only printed to
its terminal.

**What:** the lease now lasts 5 minutes and a heartbeat keeps a live
run's lease, the CLI keeps the erasure's own log lines, a scheduler
check pages on stalled erasures, and the runbook covers all three plus
how to run the CLI in production carefully.

**How:**
- `lease.py`: `LEASE_TTL_SECONDS` drops from 1 h to 5 min.
`renew_in_background` renews the lease every 60 s (5 beats per lifetime,
and above the lock's own rate limit of a tenth of the TTL) while the
erasure runs, because one step (the files sweep of a large account) can
run for minutes. The check before each step still stops an erasure whose
lease is gone. A failed beat is logged and the next one tries again.
- CLI: `logging.disable(logging.INFO)` becomes the backend's logging
with the root logger at WARNING and `backend.data.account_erasure` at
INFO, so the routine's `Account erasure of <user ID>: …` line (IDs only)
is printed and logged while Prisma and client INFO chatter stays out of
the report. It reaches Cloud Logging when the CLI is the pod's own
process. Under `kubectl exec` it only reaches the terminal, which is why
the runbook now says to save the output.
- `monitoring/account_erasure_monitor.py`:
`report_stalled_account_erasures` asks the database manager
(`stalled_account_erasures` → `request.stalled_user_ids`) for requests
with `startedAt` older than `ACCOUNT_ERASURE_STALL_ALERT_SECS` (default
3 h) and no `completedAt`, and pages Discord and Sentry with their user
IDs, at most once a day per account, like the auth identity monitor. It
is registered every `ACCOUNT_ERASURE_STALL_CHECK_INTERVAL_SECS` (default
1 h), whether or not the erasure job is on, because the CLI claims
requests too.
- `docs/engineering/account-deletion.md`: the 5-minute lease and what
"another run is erasing it" means after a crash, the stalled-erasure
check and how to clear it, and production care: check `kubectl top pod`
first, one command at a time, save the terminal output of every prod
run.

A small piece of
[SECRT-3460](https://linear.app/autogpt/issue/SECRT-3460). A second PR
adds closing without the email. Running the CLI outside a serving pod is
not in this PR.

### Changes 🏗️

- `backend/data/account_erasure/lease.py`: 5-minute TTL,
`HEARTBEAT_SECONDS = 60`, `renew_in_background` context manager.
- `backend/data/account_erasure/routine.py`: an apply holds the lease
under the heartbeat.
- `backend/cli/erase_account.py`: `_log_the_erasure_only()` replaces
`logging.disable(logging.INFO)`.
- `backend/data/account_erasure/request.py`: `stalled_user_ids`.
- `backend/data/db_manager.py`: `stalled_account_erasures` RPC (manager
and sync client).
- `backend/monitoring/account_erasure_monitor.py` (new), exported from
`backend/monitoring`; scheduler job `report_stalled_account_erasures`
and manual trigger `execute_report_stalled_account_erasures`.
- `backend/util/settings.py`:
`account_erasure_stall_check_interval_secs` (default 3600, min 60) and
`account_erasure_stall_alert_secs` (default 10800, min 1800).
- Tests: lease lifetime and heartbeat (renews during a long step,
retries a failed beat, stops with the erasure or on an error), routine
holds the heartbeat for the whole apply and not for a dry run, the CLI
logs the erasure's line and no INFO chatter, the stalled query, the
monitor (quiet, pages with IDs only, re-pages once a day), and the
scheduler registering the check with the job on or off.
- Runbook as above.

Configuration: two new optional settings with defaults (above). No
`.env.default` or `docker-compose.yml` change is needed.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pytest backend/data/account_erasure/lease_test.py routine_test.py
request_test.py backend/cli/erase_account_test.py
backend/monitoring/account_erasure_monitor_test.py` and the scheduler
registration test pass locally (mock-only)
- [x] `poetry run format` and `poetry run lint` (ruff, isort, black,
pyright) pass
  - [ ] CI runs the database-backed erasure tests
- [ ] On dev after merge: kill a CLI `--apply` mid-erasure, and a rerun
gets through within 5 minutes
- [ ] On dev after merge: a request claimed over 3 h ago with no
`completedAt` pages once

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 07:18:55 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 e7f074f161 feat(backend): one-off to delete bucket folders of deleted accounts (#16767)
### Why / What / How

**Why:** accounts deleted by hand before the erasure routine existed
left their files in the buckets, and the privacy policy says we don't
keep data of people who are gone. A read-only count found a few such
folders on prod, and the call is to delete them. Ticket: SECRT-3463
(Linear).

**What:** a one-off script, `scripts/delete_orphan_bucket_folders.py`,
and a runbook section in `docs/engineering/account-deletion.md` for
running it.

**How:** in every configured bucket, it lists the folders under an
account's prefixes (`workspaces/{workspace id}/`,
`workspaces/cli-sessions/{user id}/`, `uploads/users/{user id}/`,
`users/{user id}/`, `oauth-apps/{app id}/`, the ones the erasure sweeps)
and checks each folder name against its table with SELECTs. A folder
whose row is gone is deleted, except:

- a `users/` folder whose media a listing, a library entry, or an org,
org profile, expert or profile avatar still shows, checked again just
before deleting;
- a folder with a file from the last 7 days, which may belong to a row
created after the check;
- a folder whose row exists again on a second check just before
deleting;
- a folder not named by a UUID. `workspaces/chat-transcripts/` itself is
one: an earlier count read it as an orphan workspace, and deleting it
would have taken every account's old transcripts.

`workspaces/chat-transcripts/`, where transcripts lived until #12804, is
left entirely alone by default, and the output says it was skipped:
whether to delete the transcript folders of gone accounts is still being
decided. `--include-chat-transcripts` adds
`workspaces/chat-transcripts/{user id}/` to the sweep.

It is a dry run unless `--apply`, and prints counts per bucket and
prefix, never an ID or a file name. It deletes folder by folder; a
folder with a failed delete stays, and failures are counted by error
type and status. A prefix that hits an error prints only the error's
type and status, and the run exits non-zero. It is piped into the
database manager pod using the least memory, by name (`poetry run python
-u - < script`), so it runs on whatever image is deployed. Just before
deleting a folder it lists it again and keeps it if a file arrived since
the first listing.

### Changes 🏗️

- `autogpt_platform/backend/scripts/delete_orphan_bucket_folders.py`:
the one-off.
-
`autogpt_platform/backend/scripts/test_delete_orphan_bucket_folders.py`:
mock-only tests against a fake bucket.
- `docs/engineering/account-deletion.md`: "One-off: files of accounts
deleted before the erasure", with the commands, what it keeps, and why
dev shows preview accounts' folders as orphans (previews write to dev's
bucket with a database of their own).

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Unit tests: dry run counts and deletes nothing; apply deletes only
orphans and keeps shown, recent, reappearing and non-UUID folders; a 404
on delete is fine, another error is counted; the report line names no
folder; a prefix that fails prints no folder and fails the run; the
media-owner pattern matches every stored URL form; a file written
between the listings keeps its folder; `workspaces/chat-transcripts/` is
skipped (and says so) unless `--include-chat-transcripts`
- [x] Dry run in dev's database manager: counts per prefix, nothing
deleted
- [ ] `--apply` is run by the operator, after reading the dry run's
counts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 06:55:26 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 ab03e6a06a fix(backend): delete the PostHog person after the account's rows, and let a rerun repeat it (#16763)
### Why / What / How

**Why:** the account erasure deleted the PostHog person before the
account's rows. An event captured in that gap (Postmark's delivery
webhook for the account-closed email is the usual one with `--close
--now`, a Stripe webhook can do it too) makes PostHog create the person
again, and the request is still marked done. A later `erase-account
--apply` then printed "nothing ran: erased already", so there was no way
to clean it up with the CLI. Ticket:
[SECRT-3458](https://linear.app/autogpt/issue/SECRT-3458).

**What:** the PostHog step now runs after the database step (it needs
only the user ID; once the rows are gone, webhooks can no longer find
the account to capture events for it). And because an event already on
its way can still land after the delete, an `--apply` on a finished
erasure now repeats the PostHog delete.

**How:**
- `routine._erasure_steps`: `posthog` moved from before `mailerlite` to
after `database`, before `request`. A PostHog step that fails or isn't
configured still stops the apply before the request is marked done; the
next run resumes (the earlier steps find nothing, and PostHog runs
again).
- `routine._run`: when the claim fails because the request is completed,
it runs `_repair_steps` (the PostHog delete only) under the same lease,
instead of nothing. `not_claimed` stays `erased already` and
`completedAt` is not touched. The other outside steps find their data
through the rows (or the address they held), and nothing re-creates it
once the rows are gone, so they are not repeated. The final log line
also says when a run only repeated the delete.
- CLI: the report prints `erased already: repeats only the PostHog
delete` with the step line, and the command fails when that delete
failed (it still exits 0 when it went through).
- Runbook `docs/engineering/account-deletion.md`: step order, the
Confirm step waits two minutes and reruns `--apply` if `posthog persons`
is above 0, a note for erase-at-once requests on releases before this
fix (close first, wait, then `--close --now`, then check), and the user
ID instead of the email wherever the rows may be gone.

### Changes 🏗️

- `backend/data/account_erasure/routine.py`: step order, repair run on a
finished erasure, docstrings.
- `backend/cli/erase_account.py`: report line and exit code for the
repair run, docstring.
- Tests: `routine_test.py` (new order, PostHog after the rows, repair
run repeats only PostHog, failed repair), `cli/erase_account_test.py`
(repair output and exit codes), `account_erasure_db_test.py` (the rerun
of a finished erasure now repeats only the PostHog step).
- `docs/engineering/account-deletion.md`.

No configuration changes.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `routine_test.py`, `cli/erase_account_test.py`, `steps_test.py`,
`sweep_test.py`, `third_parties_test.py` pass locally (mock-only)
- [x] ruff, black, isort; pyright shows no new errors on the changed
files
  - [ ] `account_erasure_db_test.py` (database-backed) in CI
- [ ] After the merge, on dev: `erase-account <user ID> --apply` on a
finished erasure with a leftover PostHog person deletes it, and the dry
run two minutes later shows `persons=0`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 06:18:29 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 b81a04fc1d fix(backend): account erasure removes AutoPilot transcripts from before April 2026 (#16769)
### Why / What / How

**Why:** AutoPilot stored chat transcripts under
`workspaces/chat-transcripts/{user id}/` until #12804 moved new ones to
`workspaces/cli-sessions/{user id}/`. The older files were kept, but the
account erasure's files step only sweeps `cli-sessions`, so an erased
account that chatted before the move keeps those transcripts.

**What:** the erasure now sweeps both transcript folders, in every
bucket and, on a self-hosted stack, on disk. The existing
`chat-transcripts/` folder itself stays: this changes only what an
erasure removes.

**How:** one `_TRANSCRIPT_FOLDERS` tuple feeds both `_prefixes`
(buckets) and `_local_dirs` (the local-disk fallback), behind the same
`_TRANSCRIPT_ID` check as before, since the old code sanitised the user
ID in its paths the same way (hex and hyphens only).

Ticket: [SECRT-3469](https://linear.app/autogpt/issue/SECRT-3469)

### Changes 🏗️

- `backend/data/account_erasure/storage.py`: add
`workspaces/chat-transcripts/{user id}/` to the bucket prefixes and the
local folders of the files step.
- `backend/data/account_erasure/storage_test.py`: the fake bucket and
the self-hosted folder hold old transcripts of the account and of
another account; the tests check the account's are swept (dry run counts
and real delete) and the other account's stay.
- `docs/engineering/account-deletion.md`: step 6 lists the old prefix.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pytest backend/data/account_erasure/storage_test.py` (mock-only):
21 passed
  - [x] ruff, black, isort and pyright clean on the changed files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-13 05:48:50 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 6006036d8b feat(backend): close and erase an account without the confirmation email (erase-account --no-email) (#16768)
### Why / What / How

**Why:** closing an account always sends the account-closed email, and
an erasure sends it late when closing could not. Sometimes nobody should
hear from us about it, but an operator had no way to close and erase an
account without that email except a one-off script with the sender
patched out.

**What:** `poetry run cli erase-account <account> --close --now
--no-email` closes and erases the account without the confirmation
email. The flag works only with `--close --now`, for an owner erased at
once.

**How:**
- New column `AccountDeletionRequest.confirmationSkipped` (boolean,
default false). The migration is a catalog-only `ADD COLUMN ... DEFAULT
false`, with no table rewrite.
- `delete_account(..., send_confirmation=False)` skips
`_send_confirmation` and `close_account(..., confirmation_skipped=True)`
writes the column. Settings and the paywall keep the default (email
sent).
- The erasure's `confirmation` step reads the column
(`Subject.confirmation_skipped`) and sends nothing: `emails=0`, note
"closed without the email (--no-email)".
- For an account closed earlier whose email has not gone out
(`confirmationSentAt` null), `request.skip_confirmation` marks the
request before the erasure runs, so the erasure doesn't send it late.
- CLI: one usage check, `--no-email goes with --close --now.`, behind
the `_NO_EMAIL_ONLY_WITH` message. With the flag, the CLI prints a line
saying no email goes to the owner.
- Runbook `docs/engineering/account-deletion.md`: a short "Without the
email" part under step 3.

Part of [SECRT-3460](https://linear.app/autogpt/issue/SECRT-3460). The
lease, log line and stalled-erasure alert are in #16766. User IDs:
`skip_confirmation` updates only the row of the given `userId`, and only
operators reach it through the CLI, so no request-level ownership check
applies.

### Changes 🏗️

- `schema.prisma` and migration
`20261112120000_account_deletion_confirmation_skipped`:
`confirmationSkipped Boolean @default(false)`.
- `backend/data/account_deletion.py`: `close_account(...,
confirmation_skipped=False)`.
- `backend/api/features/account_deletion/service.py`:
`delete_account(..., send_confirmation=True)`.
- `backend/data/account_erasure/request.py`:
`close_for_email_request(..., send_email=True)`, `skip_confirmation`,
and the confirmation step honouring the column.
- `backend/data/account_erasure/models.py` and `preflight.py`:
`Subject.confirmation_skipped`.
- `backend/cli/erase_account.py`: the `--no-email` option, its check,
and a docstring line.
- Tests:
  - service: no email, and the column recorded.
- request: close without the email, `skip_confirmation`, and no late
email on dry run and apply.
- CLI: an open account, a closed account marked, a dry run marks
nothing, and the flag refused without `--close --now`.
- DB-backed (CI): three closed accounts. Only the one neither closed
with the skip nor marked gets the late email.

Configuration: one database migration (an additive column with a
default). No env or compose change.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Mock-only tests pass locally: `erase_account_test.py`,
`request_test.py`, `preflight_test.py`, `service_test.py` and
`routine_test.py`
  - [x] `poetry run format`, ruff and pyright pass on the changed files
  - [ ] CI runs the DB-backed erasure test and the migration
- [ ] On dev after merge: `--close --now --no-email --apply` on a QA
account sends no email, and its request shows `confirmationSkipped`

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 05:48:47 +00:00
Nicholas TindleandClaude Opus 5.5 938143fae0 fix(blocks/exa): verify webhook secrets on preset triggers and check signature timestamps (#16760)
### Why / What / How

**Why.** Two gaps in the Exa webhook verifier
(`backend/blocks/exa/_webhook.py`):

1. A Search Monitor trigger set up as a preset stores its
`webhook_secret` nested under `_node_input_mask_<node id>`. Every setup
path writes it that way (library Run modal,
`setup_agent_webhook_trigger`, `/presets/setup-trigger`).
`_search_monitor_secrets` read it from the top level of `preset.inputs`,
didn't find it, and verified the delivery as if no secret had been set.
2. `verify_exa_signature` signs over the `t=` timestamp but never
compared it with the clock.

**What.**
- `_search_monitor_secrets` reads preset trigger configs through
`preset_trigger_configs()`, as
`GenericWebhooksManager._configured_secret` and the All Quiet manager
already do. Nested configs are read, and a legacy flat preset is read
whole.
- `verify_exa_signature` refuses (403) a delivery whose `t` is more than
300 s from now in either direction, or doesn't parse as an integer. It
checks the signature first, so a timestamp refusal only happens for a
delivery Exa really signed, and it logs the webhook id and the clock
offset. This is the window Exa's [verification
guide](https://exa.ai/docs/websets/api/webhooks/verifying-signatures)
suggests, and the one the Stripe, RMFG and All Quiet verifiers use.

**How.** The documented "URL only until the secret is pasted" behaviour
stays: the secret only exists once the monitor is created from the
trigger's URL. A secret set on any node or preset of the webhook is
enforced. No event-ID deduplication (it needs shared state; no verifier
in the tree does it).

### Changes 🏗️

- `backend/blocks/exa/_webhook.py`: preset reader,
`SIGNATURE_TOLERANCE_SECONDS = 300`, timestamp check and log line.
- `backend/blocks/exa/exa_webhook_manager_test.py`: Search Monitor
signature tests run for node-attached, nested-preset and flat-preset
layouts; a node and a preset sharing a webhook; `TestSignatureTimestamp`
for both webhook types (±300 s edges, stale, future, unparseable);
rejection tests assert their reason.
- `backend/api/features/integrations/webhook_ingress_test.py`: fresh
timestamps in `TestExaSignature`; new
`TestExaSearchMonitorPresetTrigger` end to end through the ingress
route, with the webhook built from the rows trigger setup writes.
- `docs/integrations/block-integrations/exa/search_monitor_trigger.md`,
`webhook_blocks.md`: one sentence on the window each.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `exa_webhook_manager_test.py` + `webhook_ingress_test.py` in the
Linux backend image: 154 passed
- [x] `webhook_blocks_test.py`, `exa_platform_credentials_test.py`,
`webhook_preset_trigger_test.py`, `test_block.py -k Exa`: pass
  - [x] `scripts/generate_block_docs.py --check`: in sync
- [x] Mutation checks: old preset reader, no timestamp check, no
`abs()`, `>=` for `>`, a 0 s window, and checking the time before the
signature each fail the tests meant to catch them
- [ ] Before release: one real Exa Search Monitor and one Websets
delivery, plus one forced retry, to confirm `t` is Unix seconds and is
re-signed on retry

#### For configuration changes:
- [x] Not applicable: no configuration changes

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 03:57:34 +00:00
Nicholas TindleandClaude Opus 5.5 c58c7f0b22 docs(platform): Microsoft sign-in is set up on both Entra apps and in Vercel (#16787)
### Why / What / How

**Why:** `docs/engineering/microsoft-local-e2e.md` still said dev's
Entra app lacked step 5, and that prod's app hadn't been set up for the
Microsoft connection (#16268) or Continue with Microsoft (#16269). Both
shipped to prod in v0.11.2 on 2026-11-11, and the setup was finished on
2026-11-12. The guide also told people to rotate a client secret by
adding the new one first, and that no longer works.

**What:** the guide's Entra section now records what each environment
has, and how a secret is rotated given Microsoft's limit.

**How:** docs only. The facts come from the Entra apps' manifests and
pages, Vercel's environment settings, and read-only checks against
Microsoft's authorize endpoint and dev-builder.

### Changes 🏗️

- **Step 4 (Certificates & secrets):**
- An app that accepts personal Microsoft accounts can hold only two
client secrets. Entra refuses a third with "credential limit has been
reached".
- Each app now has two: the bot's, used by the backend as
`MICROSOFT_CLIENT_SECRET`, and Vercel's, used for
`AUTH_MICROSOFT_CLIENT_SECRET`, which expires in 2028-11.
  - So rotating one means deleting it first.
- **Status:**
- Dev's app: on 2026-10-26 it got steps 1–4, for localhost and the local
test stack. On 2026-11-12 it got `dev-builder.agpt.co`'s two redirects
and step 5.
- Prod's app: all five steps on 2026-11-12. Until then, personal
accounts couldn't connect on prod.
- Four Copilot permissions need admin consent in each organization.
agpt.co hasn't granted it.
  - Browser sign-in is set in the infra shared config (infra #498).
- Continue with Microsoft and browser sign-in are live on dev. Prod has
every setting and switches both on with its next deploy.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Each fact checked against the live systems on 2026-11-12:
- Microsoft's `/consumers` authorize endpoint serves the sign-in page
for both apps' registered callbacks, and still rejects an unregistered
one.
- Both apps' API permissions pages list the 17 delegated permissions.
    - Prod's Token configuration lists `xms_edov`.
- Dev-builder's `/api/auth/login/with-provider` returns Microsoft's
authorize URL: the dev app's client ID, dev-builder's callback, scope
`openid profile email`.
- A headless browser sees "Continue with Microsoft" on dev-builder's
`/login` and `/signup`, but not on platform.agpt.co yet.
- Dev's API and WebSocket servers' shared ConfigMaps hold
`MICROSOFT_BROWSER_SIGN_IN=true`.
    - The two-secret limit is Entra's own error on the dev app.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): none in this repository. The Entra,
Vercel and infra settings it describes are already applied.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-13 01:02:57 +00:00
Reinier van der LeerandClaude Opus 5.5 0f48ec163e fix(blocks/discord): read at most 1 MiB of a text attachment in Read Discord Messages (#16719)
Read Discord Messages now adds at most the first 1 MiB of a `.txt` or
`.py` attachment to `message_content`, and ends the text with a note
when it cut a longer file.

Resolves [SECRT-3432](https://linear.app/autogpt/issue/SECRT-3432)

### Why / What / How

**Why:** a workflow that reads Discord messages usually hands
`message_content` to a model block, and a long log or source file
attached to a message went into it in full, more text than that model
can take.

**What:** an attachment of up to 1 MiB is added exactly as before. A
longer one is cut at 1 MiB and followed by `[File truncated to its first
1048576 bytes.]`, so the workflow, and a model reading it, can tell the
file goes on. The block's doc page says so.

**How:** the download passes `max_body_bytes` to `Requests`, the same
capped read `web_fetch` uses, so the block reads no more of the file
than it keeps (the read stops one byte past the cap, which is how a
longer file is told apart). It also passes its own response limit one
byte above the cap: the client cuts only below its response limit, and
that limit defaults to `max_file_size_mb`, whose minimum of 1 would
otherwise equal the cap and refuse the file. The block keeps the first 1
MiB and decodes it with `Response.text()` as before.

Settled choices:
- A longer file is cut, not dropped: a long log keeps its first 1 MiB.
- A cut file is not an error: the block still delivers the message, and
the note says that the rest was left out.
- 1 MiB of text is about 250,000 tokens, already more than most models'
context windows.

### Changes 🏗️

- `bot_blocks.py`: `MAX_TEXT_ATTACHMENT_BYTES` (1 MiB) and the capped
read in `ReadDiscordMessagesBlock.run_bot`.
- `bot_blocks_test.py`: the block's message handler run against a local
HTTP server through the real `Requests`. A short file and one of exactly
1 MiB are added whole; a file far longer than 1 MiB is cut at 1 MiB,
with the note.
- `docs/integrations/block-integrations/discord/bot_blocks.md`: the "How
it works" section states the limit.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Verified

I ran `bot_blocks_test.py` (10 passed),
`backend/util/architecture*_test.py` (119 passed),
`backend/blocks/test/test_block.py` (4,056 passed, 84 skipped) and
`generate_block_docs.py --check` (in sync), on the head merged with
`dev`. Each of five mutations of the block turned a new test red:
removing its own response limit (red only with the file-size limit set
at the cap), removing the read cap, removing the cut, `>=` for `>`, and
removing the note. I did not run the block against Discord itself.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] A text attachment of up to 1 MiB reaches `message_content` whole
- [x] A longer one is cut at 1 MiB, with the note, also with
`max_file_size_mb` at 1
  - [x] The block, architecture and docs-sync suites pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 01:02:30 +00:00
90a7c6aebe feat(platform): let Otto answer with visual cards (#16677)
### Why / What / How

**Why:** Otto's answers read like documents: headings, bullets and
tables in markdown. Tool calls already get rich cards, but Otto's own
answers don't.

**What:** A new `render_ui` tool lets Otto show visual cards in a
message: stats, charts, workflow steps, images, timelines, tables and
one-click options. Everything ships behind `copilot-visual-answers`, off
by default. This is the first half of the plan; a declarative "canvas"
for one-off interactive visuals follows in a separate PR.

**How:** We already use the AI SDK's generative UI pattern (typed tool
parts mapped to React), so this needs no new SDK. The tool validates a
typed list of blocks with Pydantic and returns them, and the chat
renders the `tool-render_ui` part as cards outside the tool chain.

Blocks hold only plain text, numbers and `workspace://` image refs.
There is no URL, HTML or style field, so nothing a model writes can
leave the page. An option sends its visible title as the reply, so what
someone clicks is what gets sent, and any action it leads to still goes
through the usual approval.

### Changes 🏗️

**Backend**
- `render_ui` tool and block models, with caps of 6 blocks and about 16
KB per call.
- The tool is hidden on chat platforms, for callers that keep only the
final text (subtasks and the AutoPilot block, through a new
`text_only_result` session field), and when the flag is off. Scheduled
follow-ups keep it, because people read those chats.
- The schema adds about 4 KB to each turn, only where the flag is on.
Budgets in `tool_schema_test.py` are raised by that measured amount.

**Frontend**
- `tools/RenderUI/` with one component per block. Recharts loads on
demand.
- Options work only on the newest message, when no turn is running, and
outside shared chats.
- New `visual_option_clicked` PostHog event, added to the tracking plan.
- The cards use soft gradients and shadow rings. The timeline has a
connecting line, status dots and date pills; the table has a header band
and number columns aligned right; chart fills fade to the axis; workflow
steps stack on phones.
- Every block type appears on `/copilot/styleguide` under "Tool: Visual
answers", so the cards can be checked without a live chat.

**Login and signup (out of scope, small)**
- From the `lg` breakpoint the auth pages are exactly one screen tall
and no longer scroll. The login page's "What's new" list scrolls inside
its card; on a short window the signup panel scrolls on its own
(`AuthSplitLayout`, `AuthMarketingPanel`, `MarketingHeader`,
`LoginWhatsNew`).

**Configuration**
- Create the `copilot-visual-answers` flag in PostHog in each
environment. It's backend-only and defaults to off. The chat always
draws stored cards, so turning it off doesn't break old conversations.

### Agents and large language models used

Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] `render_ui_test.py`: validation, caps, null optionals, schema
shape and who is offered the tool
- [x] `tool_schema_test.py`, `capability_tools_test.py`,
`permissions_test.py`, `gate/policy_test.py`, tool names fixture
- [x] `RenderUIParts.test.tsx`: every block, sending, disabled states,
unknown blocks, stopped turns, plain-text safety
- [x] A shared fixture (`renderUIOutput.json`) ties the real tool output
to what the frontend renders, in place of a browser end-to-end test
- [x] Ran locally with the flag forced on: Otto answered a launch-plan
prompt with a timeline and a table card
- [x] Every block type checked on `/copilot/styleguide` at 1440 and 390
px, with no card overflowing
- [x] Login and signup measured at 1440×900, 1440×700 and 390 px: the
page height equals the window, and nothing overlaps
  - [ ] Check a real Otto answer on a preview with the flag on

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [ ] `pnpm lint` passes, including the `design-system/*` rules. eslint
and oxlint pass on the changed files; the full command was not run, so
CI is the check
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`). Recharts SVG attributes are
the exception, listed in `ds-colour-exempt.json` like the other charts
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [ ] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked.
`src/components/auth/AuthSplitLayout/**` changed; screenshots to be
attached
- [x] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Claude Sonnet 4.5 (Cursor Code) <[email protected]>
2026-11-12 14:53:18 +00:00
df71544e2d feat(platform): support native and browser Sign in with Apple (#16683)
### Why / What / How

Native and browser clients need Sign in with Apple on the session
foundation in #15384. This follow-up adds native identity-token exchange
and browser authorization-code login to the existing server-side Better
Auth service. Both use the same accounts, signup policy, bans, session
issuance, access-JWT renewal and logout.

Stacked on #15384 (`codex/secrt-2881-native-mobile-backend`). Native
login uses the app's registered bundle identifier. Browser login uses a
Service ID grouped under the same primary App ID, server-side signing
credentials and an HTTPS Apple callback. The existing browser login API
accepts `provider: "apple"`; this PR supplies backend support without
adding client login controls.

### Changes 🏗️

- Register native, browser-only or combined Apple configuration. Require
the nonce for direct ID-token exchange and validate browser callback
identity against the Service ID.
- Complete Apple's form POST through Better Auth's state-bound callback
into HttpOnly browser cookies. Limit Apple's trusted origin to that
callback POST; retain the foundation's native bearer export rules.
- Generate a fresh five-minute ES256 client-secret JWT for each outbound
Apple OAuth operation, including code exchange after delayed consent.
Keep signing out of shared auth initialization; malformed or
wrong-algorithm keys return a controlled HTTP 503 for browser Apple
login while email and native Apple login remain available.
- Recover omitted profile data only from the account linked to the
verified Apple subject. Preserve private relay addresses, signup
restrictions, bans and verified-email linking rules.
- Add blank `AUTH_APPLE_APP_BUNDLE_IDENTIFIER`, `AUTH_APPLE_CLIENT_ID`,
`AUTH_APPLE_TEAM_ID`, `AUTH_APPLE_KEY_ID` and `AUTH_APPLE_PRIVATE_KEY`
settings. The browser return URL is
`https://<auth-host>/api/auth/callback/apple`. [Configuration and client
contract](https://github.com/Significant-Gravitas/AutoGPT/blob/codex/secrt-2881-apple-sign-in/docs/engineering/native-mobile/apple-sign-in.md).
- Add real-handler integration tests with ephemeral Apple signing keys
and the existing browser-provider route regression. Fully disabled
deployments return the standard provider-not-found response; audience
lists contain distinct configured identifiers.
- No dependency change, schema migration, production secret or Python
API change. Scanner baseline updates only track existing test-fixture
line numbers.

### Agents and large language models used

- Codex via T3 Code: gpt-6-astra (implementation, review and
validation).
- Claude Code: Claude Opus 5.5 (base-branch merge and live browser QA
video).

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] 942 auth/session/API-client/login/signup tests, including 55 Apple
cases, with zero skips (after merging the base branch's Continue with
Microsoft).
- [x] Native Apple identity → signed session bearer → access-JWT renewal
→ logout; repeat login and private relay addresses preserve the same
account.
- [x] Browser authorization → cross-site form callback → state-bound
completion → cookie session → logout. Grouped native and browser
identities resolve to the same account.
- [x] Invalid token claims/signatures/nonces, unavailable keys, state
mismatch/replay, denied consent, rejected codes, untrusted redirects,
signup policy and partial configuration.
- [x] Client-secret verification after consent takes over five minutes;
multiline and escaped-newline private-key configuration. Three
malformed-key regressions first reproduced initialization failures, then
verified email/native sign-in and the controlled browser error without
credential logging.
- [x] Whole frontend `pnpm format`, `pnpm lint`, `pnpm types`, then
relevant `pnpm test:unit`; eight documentation-layout tests.
- [x] Live browser acceptance against real Apple
([video](https://github.com/Significant-Gravitas/AutoGPT/pull/16683#issuecomment-6785819490)):
QA App ID, Service ID and signing key with an HTTPS callback, run
against this branch's auth service and an isolated database. Covers Hide
My Email, platform user provisioning, an access JWT verified via JWKS,
reload persistence, logout, and repeat login returning the same account.
- [ ] Live native acceptance: a signed iOS build with the Sign in with
Apple entitlement against a deployed backend, including shared
native/browser account identity.

Tests use the production auth configuration and real Better Auth
cryptographic/session handling, with origin and CSRF checks enabled.
Apple network responses, database storage, DNS and platform provisioning
are controlled test boundaries. The automated tests use no real Apple
credentials; the live browser run linked above used real Apple
authorization with dedicated QA identifiers.

<details>
  <summary>Example test plan</summary>

  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
  - [ ] Import agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json` except for a moved,
renamed or split file (explained under **Changes**)
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [x] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked
- [x] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

No pixel change: only server-side auth TypeScript, tests, environment
defaults and engineering docs change. No components, markup, styles or
client sign-in controls were changed; the markup-related checklist items
are not applicable.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

---------

Co-authored-by: GPT-6 Astra (Codex via T3 Code) <[email protected]>
Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-12 04:38:59 +00:00
b16d667ded feat(platform): add native mobile server foundations (#15384)
### Why / What / How

**Breaking change when the legacy-token cutoff is enabled:** old JWTs
without `sid` must be renewed. Deploy the signer first, then set one
shared cutoff at least one access-token lifetime (one hour) after the
signer rollout completes. Unset preserves the old signature/expiry
checks for legacy tokens; `0` enforces immediately. Never renew the
cutoff on worker restart. Automatic native outbox replay remains
disabled until durable cancellation and Redis-loss recovery are
delivered.

Installed mobile and desktop clients need to authenticate without
browser cookies and retry queued sends without creating duplicate
conversation rows. This foundation adds signed Better Auth session
bearers, session-authority checks in REST/WebSocket authentication, and
stable queued-message identity through Redis and PostgreSQL.

The retry work ships alongside authentication because reconnecting
clients repeat accepted sends. Preserving those identities requires
recovery to keep accepted batches through cancellation, failed writes
and buffer refill. The engine changes preserve existing approval results
and transient-error handling around those recovery boundaries. This PR
can land independently of the client applications; it does not certify
their interfaces, OAuth callbacks, push, purchases or offline delivery.

[Client contract and
rollout](https://github.com/Significant-Gravitas/AutoGPT/blob/codex/secrt-2881-native-mobile-backend/docs/engineering/native-mobile/backend-handoff.md)
· [Server
validation](https://github.com/Significant-Gravitas/AutoGPT/blob/codex/secrt-2881-native-mobile-backend/docs/engineering/native-mobile/server-validation.md)
· [SECRT-2881](https://linear.app/autogpt/issue/SECRT-2881)

Part of SECRT-2881.

### Changes 🏗️

- Accept only signed session bearers and issue access JWTs containing
`sid`. Native credential export requires `X-AutoGPT-Native: 1` without
Cookie, Origin or browser Fetch Metadata headers. Browser sign-in and
refresh retain HttpOnly cookies without exporting a native credential.
- Validate session ownership, expiry, revocation and account bans on
authenticated REST requests and new WebSocket connections, including the
nested v1 and v2 API apps. Share validation only within one request.
Keep service-token verification separate and use one shared ban
predicate while retaining the authoritative database read.
- Preserve deployment compatibility for older signed JWTs when
`AUTH_LEGACY_TOKEN_DEADLINE` is unset. JWTs containing `sid` always
validate the session. An explicit fixed cutoff, or `0`, requires renewal
of tokens without `sid`: HTTP 401 with `X-Error-Code:
session_token_renewal_required`, or retryable WebSocket 1013. The web
client renews without losing pending subscriptions.
- Require an authenticated heartbeat before treating a WebSocket as
connected. Share bounded reconnect backoff across closing sockets and
temporary token/JWKS failures; invalid, revoked and banned sessions
remain terminal. Give each send a 30-second connection deadline while
background reconnection continues; expired sends are never delivered
later. Provider unmount disconnects its client, cancelling retry and
heartbeat timers and ignoring late token responses.
- Preserve scoped client message IDs through queue admission and
database persistence. Full queues return an explicit 429 with documented
`X-Error-Code` and `Retry-After` headers. Pending-message history lookup
failures return 503 with `Retry-After: 30` before admission, including
expert kickoff. The composer retains the draft with concise recovery
messages for full queues and temporary service outages.
- Couple receipts and accepted claims to queue expiry. Restore already
accepted batches ahead of newer arrivals without admission eviction,
retain ownership through preflight and mid-turn writes, and drain a
finite initial backlog. Preserve completed approvals across
cancellation. After cancellation just after commit, both engines avoid
repeating already-loaded history in the next model prompt while
retaining recovered images.
- Exercise production Better Auth and real PostgreSQL/Redis journeys in
a dedicated Ubuntu lane. Its coverage uses the separate `native-server`
flag; the backend coverage flag retains exactly its twelve shard
uploads. Harness startup diagnostics retain the cause while redacting
connection URLs and configured secrets.
- Account closure (#15459) composes with session authority: a new
WebSocket rejects a closed account first, with 4003 `This account is
closed.` (1011 when the lookup fails), through the same
accept-then-close path so the client receives the code. Signed-in REST
routes validate the session, then return 401 for a closed account. The
client contract lists both.
- A baseline turn refused under the running-sandbox limit puts the
queued messages it drained back with the same restore failed turns use:
ahead of newer arrivals and deduplicated by id.
- Keep the client contract and executable validation guide; remove stale
planning inventories and the audit of another client PR. Replace
line-number secret-fixture fingerprints with two narrow path-and-rule
exceptions; annotate synthetic test credentials and an upstream
curve-class false positive at their exact lines. Register the native
harness as a Knip entry point. Include formatter fixes, a type
correction in an upstream Gmail test, and portable workspace paths in
existing SDK cleanup tests found during whole-project validation.

Data ownership: auth session lookup checks both session ID and JWT
subject. Pending-message reconciliation is scoped to an already
authorized conversation. Only internal recovery can restore accepted
messages beyond the admission cap. There is no application-data
migration or new production secret.

Operational behavior: session validation adds an uncached indexed
PostgreSQL read per authenticated request/new socket; an auth database
outage returns 503/1013. Optional-auth store/marketplace requests with
an unverifiable bearer also return 503; requests without a token remain
anonymous. Failed WebSocket authentication completes the protocol
upgrade before closing, so proxy upgrade counts increase while
authenticated-connection metrics remain unchanged. The account-deletion
routes (#15459) validate the session too. Better Auth's ban revokes
every session, so a banned account can no longer use an unexpired
pre-ban JWT to reach them, as it could on dev for the rest of that JWT's
hour.

### Agents and large language models used

- Codex via T3 Code: GPT-6 Astra (implementation, review and
validation); gpt-daybreak-blue-latest (earlier focused retry/auth
reviews).
- T3 Code claudeAgent: Claude Fable 5.1 and Claude Opus 5.5 (earlier
planning and inventories).
- Claude Code: Claude Opus 5.5 (dev merges, conflict resolution, review
follow-ups and validation).

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Local runs with Python 3.12 and Node 24 against PostgreSQL, a
Redis cluster, RabbitMQ and ClamAV: this PR's auth, WebSocket and
pending-message suites, the account-deletion, ban and closed-account
suites, the chat routes, the architecture and block guards and the
native lane pass; the frontend suite, format, lint and types pass. Each
behavioural change was undone in a scratch copy and its test failed.
Per-run figures are in the PR conversation.
- [x] Browser/native authentication: real signed email sessions,
renewal, logout, expiry, bans, ownership mismatch, unavailable lookups,
unsigned-bearer refusal, and JWKS recovery; browser sign-in and daily
refresh do not export native credentials.
- [x] Rolling deployment: legacy JWTs before/at/after a fixed cutoff,
service-token separation, and WebSocket renewal preserving pending
subscriptions.
- [x] Queued messages: concurrent HTTP retries, full-buffer refusal,
receipt/list/claim expiry, rollback after refill, finite drains,
repeated cancellation and persistence exactly once. Real
PostgreSQL/Redis cancellation immediately after commit followed through
the next model input, for text and images.
- [x] The separate WebSocket process authenticates through
database-manager RPC without its own usable database URL.
- [x] Whole backend/library formatting and type checks; whole frontend
format, lint and types; API schema and generated client regeneration.

Local validation uses Python 3.12 and Node 24.18 with isolated test
services. Hosted checks and review are evaluated on the published head.
The repository's Knip report remains informational and reports existing
unused-code findings. Native-device acceptance and live
identity/payment/push providers are outside this server foundation.

#### For frontend changes:

- [x] `pnpm lint` passes, including design-system rules; no baseline
counts were raised.
- [x] No new legacy components, raw colours, dark/light classes, or
styled primitive overrides.
- [x] No pixel change: the product diff changes auth, WebSocket behavior
and error copy; component markup and styling are unchanged.
- [x] User-facing copy follows workflow/Otto terminology; identifiers
retain their API meaning.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] Configuration changes are listed above: optional fixed
`AUTH_LEGACY_TOKEN_DEADLINE`, independent native coverage flag, pinned
CI actions and test harness configuration.

---------

Co-authored-by: GPT-6 Astra (Codex via T3 Code) <[email protected]>
Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-12 03:41:29 +00:00
Nicholas TindleandClaude Opus 5.5 1cbcb80e66 fix(platform): self-host auth DB connection budget, visible token failures, build memory (#15440)
### Why / What / How

**Why.** These are the backend and appliance findings from the self-host
smoke on dev `92b44302` (patrol W51). None is a regression.

- **N2 (S2, intermittent): concurrent sign-ups ran the frontend out of
Postgres connections.** During a burst of sign-ups the appliance logged
`FATAL: too many connections for role "autogpt_frontend"`, then `Failed
to get auth token`. The affected requests then went to the backend
**without a token**, and the backend's 401 reads as a logout.
- Root cause: a production Next build carries **two copies of
`lib/auth/auth.ts`**: route handlers in one chunk, and the pages' server
actions in another. In the appliance image they are `chunks/1657.js` and
`chunks/2443.js`, each running its own `new Pool(...)`.
- The `globalThis` cache that should make the pool a singleton only ran
outside production. That gives **two pools of 10 against a role limit of
10** (`bootstrap.sh`), so a sign-up (server action) and a page's API
calls (route handlers) push the role past its limit. Current dev still
has that production-only gap.
- On top of that, `getServerAuthToken` turned every failure into `null`,
which means "not signed in", so nothing on the way failed visibly.
- **N4: building the appliance from source peaked at ~8.2 GiB**, and one
attempt was OOM-killed in an 8 GiB builder during "checking validity of
types".
- The Dockerfile's `NODE_OPTIONS=--max-old-space-size=8192` never
applied: `pnpm build` runs `cross-env
NODE_OPTIONS=--max-old-space-size=16384 next build`, which replaces it.
- **F10 (a warning on every flag read) is no longer in this PR.** It
fixed LaunchDarkly with no SDK key. Dev has since removed LaunchDarkly
(#15853, merged 11-02), and its PostHog path is already quiet without a
key: one warning when the flag client is first built, then no SDK client
and no user lookup on any read. A proof run on dev `5d13d3bc` shows it
(see the test plan) and goes red when either guard is taken out. Dev's
own `TestPostHogWithoutAKey` and
`test_an_unconfigured_deployment_does_not_retry_forever` already pin
both halves, so nothing is added. Two edges stay as they are on dev: an
experiment-arm read (`evaluate_experiment_variant`) still looks the user
up, cached for a day, before finding no client; and the `@feature_flag`
decorator warns on every call without a key, but nothing outside tests
uses it.

**What / How.**

- **One auth pool per process, with headroom.**
- `lib/auth/auth-db-pool.ts` caches the pool on `globalThis` in every
environment, sets an explicit `max: 10`, and adds a 10 s acquire
timeout, so a starved request fails instead of hanging. It keeps dev's
changes to the pool: no fallback connection string (#15970), and Better
Auth 1.7's Kysely `PostgresDialect` with `transaction: false` (#16419)
runs on this pool.
- `bootstrap.sh` sets the role's limit to **20**, twice the pool,
because Postgres counts a connection until its backend exits. The CI
smoke check and a single-container budget test (`role limit >= 2 x pool
max`) follow it.
- **Docker-compose self-hosting** (checked): the frontend connects as
the `postgres` superuser, so no role limit applies there. It did carry
the same two-pools bug (up to 20 connections), and that is now 10.
- Unchanged and pre-existing: compose's backend Prisma pools
(`DB_CONNECTION_LIMIT=12` x 7 services, 8 with the `bot` profile), the
scheduler's 3, and the frontend's 10 can in theory reach 97-109 against
Postgres's default `max_connections` of 100. That needs its own measured
change; it's flagged for a follow-up.
- **A token that can't be minted is an error, not "signed out".** Both
`getServerAuthToken` variants return `null` only when there is no
session: no cookie, or Better Auth's 401. Anything else throws
`AuthTokenUnavailableError`, an `ApiError` with status 503. The
in-process variant decides "Better Auth said no" with #15386's
`isSessionRejection`, the same check the session read uses.
- The proxy, chat stream, upload and transcribe routes answer it with
**503 + `Retry-After`** and never forward the request. The custom
mutator stops sending server-side calls without the token.
  - React Query retries the 503. A 401 would have signed the user out.
- Limitation: Better Auth (still in 1.7.16, where `getSessionFromCtx`
turns any error into "no session") reports a failed *session lookup*
inside the token mint as a 401, so only failures after it can be told
apart. That covers the signing-key read, where the 5-minute session
cookie cache sends most mints, and it is where the appliance failed.
- Not covered, unchanged from dev: the `getWebSocketToken` server action
catches the error and returns `{ token: null, error }`, and its callers
(the copilot's auth headers, direct upload, the legacy WebSocket) treat
any `error` or missing token as signed out, as they already do for a
`null` mint on dev. Those callers can now tell the two apart from
`error`; that is a follow-up.
- **Build memory (N4): not lowered, documented.** Measured on tower's 8
GiB builder: a build with an *effective* 6 GiB cap passed but still
peaked at **7.13 GiB** anon in the builder, against **7.68 GiB** for
W51's uncapped build (concurrency differed, so not a controlled
comparison). Its main `next build` process sat at the cap (6.2 GiB RSS).
A lower cap therefore doesn't get the build under 8 GiB and only removes
headroom.
- The Dockerfile drops the dead `NODE_OPTIONS=8192`, which never
applied: `pnpm build` runs `cross-env
NODE_OPTIONS=--max-old-space-size=16384` (still so on dev), and V8
reports a 16576 MiB limit. No behaviour change.
- `docs/platform/single-container.md` and
`docs/platform/getting-started.md` (docker compose builds the same
frontend) now say building from source needs **more than 8 GiB**
available to Docker. "At least 8" would read as "8 is enough", and one
of the three builds measured in an 8 GiB builder was OOM-killed.
- A controlled frontend-only A/B on the same builder (dev's line against
an effective 6 GiB cap) couldn't run, because the shared build host's
builder went down. The decision doesn't depend on it: a cap that saved a
few hundred MiB would still leave the build over what an 8 GB host can
give Docker.

### Changes 🏗️

- `frontend/src/lib/auth/auth-db-pool.ts` (new), `auth.ts`: the
singleton pool, `max: 10`, `connectionTimeoutMillis: 10_000`.
- `single-container/bootstrap.sh`: `CONNECTION LIMIT 20`; the
`.github/scripts/platform-single-container-smoke.sh` check and
`single-container/tests/test_frontend_environment.py` (the budget
invariant) follow it.
- `frontend/src/lib/autogpt-server-api/helpers.ts`:
`AuthTokenUnavailableError`; the SSR token variant throws on a non-401
failure.
- `frontend/src/lib/auth/server/getServerAuthToken.ts`,
`authTokenUnavailableResponse.ts`, and the `proxy`, `chat/.../stream`,
`workspace/files/upload` and `transcribe` routes: answer 503.
- `frontend/src/app/api/mutators/custom-mutator.ts`: no unauthenticated
fallback for a signed-in request.
- `single-container/Dockerfile`: the dead `NODE_OPTIONS` goes.
`docs/platform/single-container.md`, `docs/platform/getting-started.md`:
the memory a build from source needs.
- Tests:
- `lib/auth/__tests__/auth-db-pool.test.ts`, the seeded randomized test
the patrol asks for on concurrency fixes. Over 300 seeds it imports the
pool module as 1–3 "bundle copies" and runs 10–70 concurrent queries
with random arrival, hold times and statement failures. These go through
the real `pg-pool` against a fake Postgres that enforces
`bootstrap.sh`'s limit and keeps a closed connection's slot for a tick.
Invariant: no refusal, and the peak never exceeds the role limit. **Red
with the old pool** (seed 2: 5x `too many connections for role
"autogpt_frontend"`); green now; stable over repeats.
- `playwright/journeys/signup-burst-selfhost-journey.spec.ts` (selfhost
project, not in CI): users sign up through the UI in two overlapping
waves while those already in hammer the API. Every sign-up must land in
onboarding and every signed-in call must answer 200.
- Against an appliance built from this branch with #15439 (`patrol-w53`
on tower): **5/5 green** (6 users ×3, 10 users ×2). Postgres logged 0
`too many connections` and the frontend logged 0 failed token mints.
- Against dev's own image (`autogpt-patrol-selfhost:bc671d371823`, this
branch's base then) on fresh data: **fails at 6 users**. Postgres logged
**36** `too many connections for role "autogpt_frontend"` and the
frontend **18** `Failed to get auth token`, and 15 signed-in API calls
came back **401**: the unauthenticated-backend symptom from W51,
reproduced on demand.
- The auth-token tests (both variants, proxy route, custom mutator) pin
the new contract: 401 gives null; a DB error, a 500 or a network failure
gives 503; outside a request, null; the proxy returns 503 without
calling the backend.
- Review follow-ups:
- The in-process mint reads `cookies()` inside a try, like the SSR
variant.
- The randomized pool test lets closed connections free their slots
before the next seed. Before, `open` went negative and `peak` was
under-counted. It asserts `peak > 0` and that every connection closes,
and has a 30 s budget.
- Merge of dev `5d13d3bc` (11-09), 850 commits:
- `auth.ts`: dev's Better Auth 1.7 imports and Kysely dialect stay; the
pool comes from `auth-db-pool.ts`, which takes #15970's no-fallback
connection string.
- Proxy route and its test: both sides' imports. Custom-mutator test:
dev's banned-account tests and this branch's server-side test both stay;
the server-side test runs last and resets the two mocks it sets.
- Backend: all of this branch's backend changes were F10 and its
LaunchDarkly test helpers, so the backend now matches dev exactly (see
F10 above).

### Risk

- **Hosted.** One pool per serverless process instead of up to two, so
fewer connections. A pool that is out of connections now fails after 10
s instead of waiting forever. A failed token mint gives a retryable 503
instead of a 401 that looks like a logout.
- **Self-hosted.** On the next boot, `bootstrap.sh` re-applies the role
and raises its limit to 20. The budget is max_connections 100: backend
Prisma pools at 5 per service, plus the scheduler pool, plus 20.
- No backend code changes any more, and no flag behaviour changes.

### CI note

The old head's red "Build, smoke, and scan (linux/amd64)" (and "Check PR
Status" with it) was not this change: the build failed importing a layer
from the GHCR build cache (`failed to authorize: Unavailable: error
reading from server: EOF`) at `COPY autogpt_libs`, before any of this
PR's files mattered, while arm64 of the same commit passed. That is the
buildkit cache-session race described in #15225, which is still open, so
the job can still flake that way; a re-run clears it.

### Agents and large language models used

Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  - After the dev merge (head `4636deae98`):
- [x] Vitest over `src/lib/auth`, `src/lib/autogpt-server-api` and
`src/app/api` (58 files, plus `src/lib/auth-redirect.test.ts` through
the path filter): 850 passed, the randomized pool test included
- [x] `pnpm format` (no changes), `pnpm lint` and `pnpm types` over the
whole frontend
- [x] `single-container/tests` through CI's runner: 259 passed, 1
allowed skip; includes the role-limit budget invariant
- [x] Backend lint as CI runs it: `poetry run lint --skip-pyright`, and
`pyright` with 0 errors. The full `poetry run lint` also points pyright
at `backend/test/` and fails there on 4 errors dev has as well; this
branch has no backend diff
- [x] `backend/util/feature_flag/` and
`backend/data/subscription_trial_config_test.py`, the files the merge
resolved (now dev's): 263 passed
- [x] Dev's guards on a private Postgres/Redis/RabbitMQ: the whole of
`backend/api` (both paywall route guards included),
`backend/util/architecture*_test.py` and
`copilot/tools/tool_schema_test.py`: 6528 passed, 12 xfailed, 18 failed.
The same 18 fail on a pure dev `5d13d3bc` snapshot. 17 need ClamAV and
pass on both dev and this branch once it runs; the last needs CI's
3-shard Redis cluster
- [x] F10 proof on pure dev `5d13d3bc`: with no PostHog key, startup
plus 35 calls across seven read APIs log one warning, never build an SDK
client and never look the user up. The same test fails when the client
stops remembering that it tried (a warning per read) or when reads skip
the no-client short-circuit (20 lookups)
  - Before the dev merge (head `033e3eafc7`, 10-12), not re-run since:
- [x] Appliance built from this branch on tower: role limit 20, one pool
per process in both auth chunks
- [x] Signup-burst journey: fails against dev's appliance (36 FATALs,
15× 401), passes 5/5 against this branch's (0 FATALs)
- [x] The randomized pool test is red with dev's pool and green with
this one

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json` except for a moved,
renamed or split file (explained under **Changes**)
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [x] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked: no pixel change; the diff
touches no component, stylesheet or Tailwind config, only route
handlers, `lib/` modules and tests
- [x] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): the appliance's frontend role
connection limit goes from 10 to 20; no new settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-12 02:26:11 +00:00
9789d6d655 fix(backend): let a run leave an input blank when Advanced is on (#16706)
### Why / What / How

**Why:** Optional workflow inputs don't work (#10614). An input only
counted as optional when it had a default value. The Advanced toggle
only took effect when a default was set, and the builder won't save a
blank default, so there was no way to make an input that is both empty
and optional. The library run form kept Run disabled until it was
filled. Experts have likely built workflows with Advanced on expecting
the input to be optional, and those are broken today.

There was a second problem behind the first: a blank input sent nothing
on, so every block wired to it waited forever and the run finished with
no outputs.

**What:** Turning on Advanced now makes an input optional. A run can
leave it blank, and the blocks it feeds still run, using their own pin
defaults. Workflows already built with Advanced on start working without
being edited.

**How:**
- `BaseGraph._generate_schema` asks each input for `is_optional()`
instead of checking `value is None`. An input is optional when it has a
default or Advanced is on, so it leaves `required`. The library run
form, the external API (`assert_inputs_match`) and Otto's `run_agent`
(`missing_inputs`) all read that list, so no frontend change is needed.
- `AgentOutputBlock.Input.is_optional()` keeps the old rule, so the
output schemas are unchanged. The dry-run simulator reads their
`required` list.
- A blank optional input sends `None`. The link then counts as filled,
so the next block isn't left waiting. `Block` builds its input model
with `None` values dropped (`_base.py`, around line 1081), so an
optional pin falls back to its own default. A required pin given `None`
is skipped, as before.

### Changes 🏗️

- `backend/blocks/io.py`: `is_optional()` on the input and output block
schemas. The base input block and the file input send `None` when blank
and optional. Removed the Google Drive input's `run`, which copied the
base one. New help text for the toggle: "Whether the input is optional,
so a run can leave it blank."
- `backend/data/graph.py`: `advanced` and `required` in the graph input
schema now come from `is_optional()`.
- Tests:
- New `backend/data/graph_optional_inputs_test.py`: which inputs end up
required, outputs unchanged, and what a blank input sends.
- New run test `test_a_run_can_leave_an_optional_input_blank` in
`backend/executor/manager_test.py`: one required input and one blank
Advanced input feed a Store Value block; the run gets only the required
value, and the block must complete with it.
- `test_get_input_schema` and the `grph_in_schm` snapshot asserted the
old rule (an Advanced input with no default was required), so both are
updated.
- `docs/integrations/block-integrations/basic.md`: the new toggle text,
edited by hand to match what `generate_block_docs.py` would write. The
script couldn't import locally because of an out-of-date Prisma client.

Follow-up, not in this PR: the toggle still reads "Advanced", and Otto's
setup card hides Advanced inputs behind "Show advanced fields". Renaming
it to "Optional" means a schema change.

Additional review fixes:
- Blank optional tables send `None`; an explicit empty table stays `[]`.
- A literal `None` output also populates links that select a dictionary
key or list item. This applies to every block that produces `None`,
matching direct-link behavior; missing children on non-null outputs
still leave their links waiting.
- Tests cover blank Drive inputs, table defaults, and six full-run cases
with direct and nested links. A second downstream block checks its
non-null schema default through its output.
- Existing inputs older than a day still count when filling a delayed
execution; a new database regression test covers this.
- Public docs explain the change for existing workflows and how to keep
an input required.
- `data/graph.py` only changes schema generation. The
`data/execution.py` query retains its node and run filters; callers keep
the same access checks, so no new user ID check is needed.
- Local validation: 25 routing and optional-input tests pass; the
delayed-input database regression passes. Changed files pass pyright.
The final architecture/routing/input run passes all 144 cases. The broad
architecture/block run had 4,172 passes; all 12 scanner-related failures
pass on retry once the local scanner is healthy. All eight docs-layout
checks pass. Full-run tests need CI because local service ports are in
use.

### Agents and large language models used

- Claude Code with Claude Opus 5.5 (`claude-opus-5-5`)
- Codex with GPT-6

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] `backend/data/graph_optional_inputs_test.py` passes locally (15
passed)
  - [x] black, isort, ruff and pyright are clean on the changed files
- [ ] `test_get_input_schema` and
`test_a_run_can_leave_an_optional_input_blank` need the database and
test server; CI runs them
- [ ] `backend/blocks/test/test_block.py` (the Google Drive input now
uses the base `run`); CI runs it
- [ ] Manual: in the builder, add an input block with Advanced on and no
default, wire it into an optional pin, and check that the library run
form allows Run with it blank and the workflow completes

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [ ] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json` except for a moved,
renamed or split file (explained under **Changes**)
- [ ] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`)
- [ ] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [ ] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [ ] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked
- [ ] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

#### For configuration changes:

- [ ] `.env.default` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

Fixes #10614

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: GPT-6 (Codex) <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
2026-11-11 23:41:53 +09:00
Krzysztof CzerwinskiandClaude Opus 5.5 06340e194f fix(backend): account erasure deletes the account's chats (SECRT-2854) (#16745)
### Why / What / How

**Why:** account erasure (#15487) left an account's chats to the `User`
row's `ON DELETE CASCADE`. Testing it on dev showed that dev's database
lacks that foreign key (`ChatSession_userId_fkey`; the schema and the
migration that created it both still have it, so the database has
drifted). So after an erasure the account's chats and their messages
stayed, and the search backfill re-indexed their titles under the erased
account's ID. Chats are the most personal data an account has, and the
privacy policy promises they go.

**What:** the erasure's database step now deletes the account's chats
itself instead of relying on the database to delete them with the
account.

**How:** one more entry in the explicit deletions (`chat_sessions`,
`ChatSession` where `userId` is the account), in the same transaction as
the private search embeddings, so the backfill never sees a session
whose embedding is gone. Everything else that hangs off a session
(messages, shared files, links, background tasks, callback tokens)
cascades from `ChatSession`, and those foreign keys are present on dev.

### Changes 🏗️

- `account_erasure/database.py`: delete `ChatSession` rows of the
account explicitly, before the orgs, beside the private embeddings.
- `database_test.py`: `chat_sessions` is among the rows deleted
explicitly.
- `account_erasure_db_test.py`: the end-to-end erasure now seeds a
titled chat with a message and checks that the chat, its message and
every embedding of the account are gone.
- Runbook (`docs/engineering/account-deletion.md`): chats are deleted by
the erasure itself, and why.

Follow-up outside this PR: dev's database is missing that foreign key.
Whether prod is too is being checked separately; this PR makes the
erasure correct either way.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  - [x] Black, isort and ruff on the changed files
- [ ] `database_test.py` and the end-to-end `account_erasure_db_test.py`
(database-backed: CI)
- [ ] On dev after merge: erase a test account that has a titled chat,
then check no `ChatSession` or `UnifiedContentEmbedding` row of its ID
is left, including after the next search backfill

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-11 13:34:37 +00:00
Reinier van der LeerandClaude Opus 5.5 b6e79fc245 docs(platform): document every rate limit, and how the Platform finds a caller's IP address (#15872)
Adds a docs page that lists every rate limit the Platform enforces, and
a second page that explains how the Platform determines a caller's IP
address, which several of those limits count by. It also tells
contributors to update the pages with any limit they change: a line in
the backend and frontend `AGENTS.md`, and a comment above each limit's
constants pointing at its page. Resolves #15869. Part of
[REQ-65](https://linear.app/autogpt/issue/REQ-65).

### Why / What / How

**Why:** someone whose chat is paused, whose request gets a `429`, or
whose store search starts returning plainer results can't find out today
which limit they hit, what it counts, or when it resets. The limits are
spread over a dozen modules, and their numbers and settings appear
nowhere outside the code. An API integrator planning load, or a
self-hoster deciding what to set, has the same gap. Several limits count
per IP address, and the backend and frontend read that address in
different ways; three settings decide whether it is the visitor's real
one, and no page says so. And a page like this goes stale the first time
someone changes a limit without knowing the page exists.

**What:** `docs/platform/rate-limits.md` gives each limit what it
covers, what it counts per (account, IP address, or everyone together),
its default number and window, what a caller sees on hitting it (status,
message, `Retry-After`, or a queued or simpler result), and the setting
that changes it. Sections:

- Chat with Otto and your Experts:
  - the daily and weekly allowance
  - the trial allowance
  - resets with credits
  - tasks running at once
  - follow-up messages
  - workflow runs per chat
- Experts:
  - the weekly budget
  - spend approval
  - results posted to the Expert's chat
  - avatar generation
- Workflows:
- runs at once, and the share of an executor's workers one account's
runs may hold, sub-workflows included
  - schedules per account
  - generated workflow images
  - emails the Send Email To Me block sends to the account
- Sandboxes: how many run at once per account, and the optional ceiling
per install.
- Search: global search and store search.
- Plans and billing: the billing page, and free trial places.
- Onboarding personalization.
- Sign-in with a code, for integrations.
- Signing in and account emails: the sign-in and password-reset attempt
limits, the per-IP and per-address email caps, and Better Auth's own
limits by path.
- Closing your account: answers to the exit question.
- The External API: v2's shared per-account and per-IP limits, v1's two
per-account tiers, and running a block on its own.

`docs/platform/client-ip.md` covers:

- the backend: the connecting address, or `X-Forwarded-For` only from
proxies in `FORWARDED_ALLOW_IPS`;
- the External API's own per-IP limits, which read the forwarded chain
by `TRUSTED_PROXY_COUNT` instead;
- the frontend: `X-Forwarded-For` only on Vercel, with
`TRUST_X_FORWARDED_FOR=true`, or with `TRUSTED_PROXIES` naming the
proxies in front of it;
- the signed `X-Client-IP-Token` the frontend passes to the backend for
store search;
- the five things on the sign-in path that read the address, each with
the two conditions it needs;
- a table of what to set for each kind of install.

The rate-limit page links to it wherever a limit counts per IP address.

To keep the pages current, `backend/AGENTS.md` and `frontend/AGENTS.md`
each gain a Code Style line: a PR that adds, removes or changes a rate
limit updates `rate-limits.md`, and one that changes how a caller's IP
address is found updates `client-ip.md`, in the same PR. Each limit's
constants or settings, and each reader of the client IP, carry a
one-line comment naming the page that documents them: 51 comments in 27
files.

**How:** every number, message and setting was read from the code at
`dev` `dc36bbef61` and re-checked at `c27bf0b975`, `531bf37603` and
`2dbf60b8ec`; comments on this PR give a `file:line` for each one.
Better Auth's built-in limits were read from the installed package,
better-auth 1.7.16, which is what `dev`'s lockfile pins. Choices made
along the way:

- **Defaults, not Cloud's figures.** The page gives the code's defaults.
For a Cloud user's own allowance it points to the pricing page and the
chat's **Usage limits** popover, because Cloud sets plan allowances
outside the code and can change them without a release.
- **The sign-in path is a table of two conditions, not prose per
limit.** Everything there reads the address through Better Auth, which
needs `X-Forwarded-For` to carry exactly one entry unless
`TRUSTED_PROXIES` names the proxies; three of the five also need the
frontend to trust the header. The table is one row per reader, with what
happens when there is no address.
- **The External API section gives v2's shared limit and v1's two
tiers.** v2's per-endpoint limits are already listed in the API's own
conventions page, so this page links there rather than repeating the
numbers.
- **Size limits are left out**, because they have no time window. That
covers upload sizes, storage per plan and query length. So are limits of
other services, such as an AI provider's 429s, which the Platform passes
on unchanged.
- **Server capacity limits are left out too**: file previews, virus
scans and the executors' worker pools. They never refuse a caller, only
make one wait. The share of an executor's workers one account may hold
is per account, so it is on the page with runs at once.
- **A pointer comment per limit, not a test.** A test that fails when a
limit is missing from the page needs one place every limit goes through,
and there isn't one: seven limiters call `record_rate_limit_hit` and
four use the shared `RateLimiter`, while the chat allowance, the turn
caps, the worker share, the Expert budgets, the schedule cap, the
device-code throttle and the frontend's sign-in limits share nothing.
The page also names limits by what a user does, not by an identifier a
test could match.

### Changes 🏗️

- New `docs/platform/rate-limits.md`, listed under "Using the Platform"
in `docs/platform/SUMMARY.md`.
- New `docs/platform/client-ip.md`, listed under "Self-Hosting".
- `autogpt_platform/backend/AGENTS.md` and
`autogpt_platform/frontend/AGENTS.md`: the line above.
- A one-line comment naming its page above each documented limit's
constants or settings and each client-IP reader, in 22 backend modules
and 5 frontend files. No code changes.

### Verified

I read every limit on the pages in source at `dev` `dc36bbef61`,
re-checked each against `dev` `c27bf0b975`, `531bf37603` and
`2dbf60b8ec` after merging them, and ran
`.github/workflows/scripts/test_docs_layout.py`: 8 passed. With
`client-ip.md` removed from `SUMMARY.md` it fails and names the page. On
the comment-only backend changes I ran
`backend/util/architecture*_test.py` and
`backend/blocks/test/test_block.py` (4175 passed, 84 skipped), and
black, isort and ruff on the touched modules (ruff reports only findings
that were already there). Pages render only on GitBook, so nothing here
was rendered locally.

What the IP page claims, and where each came from:

- **Better Auth needs exactly one `X-Forwarded-For` entry unless
`TRUSTED_PROXIES` is set.** `getIPFromHeader` in the installed
`@better-auth/[email protected]` returns `null` unless the header holds one
address, when no trusted proxies are configured
(`dist/utils/ip.mjs:190`); with some, it returns the first entry from
the right they don't cover (`:180-188`). Its rate limiter keys every
caller without an address on one bucket and logs a warning
(`better-auth/dist/api/rate-limiter/index.mjs:244-248`).
- **nginx's `$proxy_add_x_forwarded_for` gives a browser exactly one
entry**, because it appends the connecting address to whatever arrived
and a browser sends nothing; it reaches two only through a second proxy,
or a request that already carried the header. With no `proxy_set_header
X-Forwarded-For` line nginx passes the header through as it arrived.
- **The five sign-in readers and their conditions:** Better Auth's own
limiter and the per-IP email cap (`ip-email-cap.ts:26`) call `getIP`
alone; the attempt limits (`attempt-limits.ts:69-71`), the sign-in
history and IP blocks (`auth.ts:125-129`, `:253`) wrap it in
`getTrustedClientIP`, which returns `null` unless the frontend runs on
Vercel or has `TRUST_X_FORWARDED_FOR=true` or a non-empty
`TRUSTED_PROXIES` (`client-ip.ts:15-21` at `c27bf0b975`).
- **`FORWARDED_ALLOW_IPS` accepts a CIDR range.** uvicorn 0.53.0, the
version `dev`'s lockfile pins, parses each entry as an address or a
network: `"172.18.250.9" in _TrustedHosts("172.18.0.0/16,127.0.0.1")` is
`True` and `"172.19.0.1"` is `False`. Its default is `127.0.0.1,::1`
(`uvicorn/config.py:363`).
- **`FORWARDED_ALLOW_IPS` under Docker Compose is not the proxy's own
address.** Measured with a throwaway container published on a Docker
bridge network: the backend saw the connection from the network's
gateway, not `127.0.0.1` or the proxy's address.
- **`TRUSTED_PROXY_COUNT` defaults to 3**
(`backend/util/settings.py:539`), and the External API's per-IP limit
takes the entry that many from the right, falling back to the connection
address when the header has fewer
(`external/v2/global_rate_limit.py:190-212`).
`single-container/entrypoint.sh:156` sets it to 1.
- **The External API's shared numbers** are 200 a minute per account and
5 per IP, with 60 per IP for its docs paths
(`external/v2/global_rate_limit.py:47-65`), which match what the API
publishes in its own description (`external/v2/app.py:35-43`).
- **The sign-in and password-reset attempt limits** are `ATTEMPT_LIMITS`
in `attempt-limits.ts:24-42`; the refund on a successful sign-in is
`refundSuccessfulAttempt` (`:88`), and the message and `X-Retry-After`
header are at `:126-132`.

The "100 per 10 seconds" default for other `/api/auth/` paths is
`better-auth/dist/context/create-context.mjs:173-174` in 1.7.16.

### Agents and large language models used

- Claude Code with Claude Opus 5.5
- Claude Code with Claude Opus 5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Every number, message and setting traced to source (evidence
comment)
  - [x] `test_docs_layout.py` passes with both pages listed
- [x] Every cross-page anchor the two pages use resolves to a heading on
the target page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-11 13:12:45 +00:00
Krzysztof CzerwinskiandClaude 2dbf60b8ec feat(platform): erase closed accounts after #15459's 14 days, keeping a ban record (erase-account CLI and job) (#15487)
> **Stacked on #15459** (SECRT-2877, self-serve "Delete account"). The
base of this PR is #15459's branch, so the diff shows only the erasure.
Once #15459 merges, this PR gets retargeted to `dev`, and it must merge
after #15459.

### How it fits #15459's closing flow

#15459 closes the account without banning sign-in; the owner has **14
days** (`eraseAfter`) to sign in and keep it, and `startedAt` on the
request marks the erasure's claim. The erasure follows that contract
point by point:

1. **Selection:** pending requests are `completedAt IS NULL AND
eraseAfter <= now()`, oldest first, not filtered on `startedAt` (a
stopped erasure is resumed). The 15-minute `PENDING_GRACE` is gone.
2. **Claim before any step:** `request.claim_request` runs its own short
statement, `UPDATE ... SET "startedAt" = COALESCE("startedAt", now() AT
TIME ZONE 'UTC') WHERE "userId" = $1 AND "completedAt" IS NULL AND
"eraseAfter" <= now() AT TIME ZONE 'UTC' RETURNING "id"` (the columns
hold UTC without a zone). No row → nothing runs (`report.not_claimed`:
kept by its owner, not due, or erased already). No lock is held across
the erasure. The claim reads the database clock while Keep compares with
the app server's clock; a skew only moves the edge of the 14 days, since
each statement re-checks its condition on the locked row and Keep
deletes only an unclaimed row.
3. **`startedAt` is never cleared.** Recovery is a rerun of the
idempotent routine.
4. **CLI:** `erase-account <account>` refuses inside the 14 days (new
`not_due` blocker, with the date). `--close` now **only closes** (the
owner then has 14 days, as from Settings) and prints the date.
5. **Late confirmation:** when the erasure sends the email closing could
not, the 14 days are over, so it says "everything in your account is
being erased now" instead of "Sign in by <date>". The `EMAIL` reason
line stays.
6. **`complete_request`** marks the claimed row only (its ID,
`startedAt` set).
7. **No banned refusal:** `AccountBannedError`, `--allow-banned`,
`banned_for_another_reason` and the `banned` blocker are gone; #15459's
`close_account` (no ban fields) is used as it is. A banned account may
delete itself.
8. **Ban record:** when the erased account is still banned, the erasure
keeps an `AccountBanRecord` (new table): a keyed hash of the address,
the date and a category, until the ban would have ended and **3 years**
at most; each sweep purges expired ones. Sign-up checks it (below).
9. **Frontend:** no change to #15459's sign-in or sign-out code; the
only frontend change is the sign-up check.
10. **Memory crons:** closing now leaves them registered (so they resume
if the account is kept), so the erasure's work step removes the
nightly-batch, community-rebuild and skill-learning crons of the account
and each of its experts.
11. **Status:** `GET /api/account/deletion` already answers `too_late`
once `startedAt` is set; no new status.
12. **Runbook** rewritten for all of the above.

And for running unattended:
- **Bounded retries:** PostHog and MailerLite calls retry a 5xx at most
three times, then fail the step.
- **Rows that would outlive the account:** a preset's saved inputs and a
notification batch's events (`ON DELETE SET NULL`) are deleted
explicitly.
- **Errors outside the steps** (reading the account, the lease, the
claim) skip only that account, logged by type; the ban-record purge runs
on its own (and in `--pending --apply`).
- **Team agents** are handed over only while another active team member
can open them; one only the account could open is deleted.
- **One erasure per account at a time:** a Redis lease per account
(`lease.py`, 1 hour TTL, renewed before each step; the erasure stops if
it was lost) around each apply keeps two database manager replicas, or
the CLI beside the job, off the same account. With Redis down nothing is
erased.
- The memory step builds its FalkorDB client off the event loop with the
transport deadlines; the work step fails while schedules outlive
`stop_user_work`; a failed step logs only its exception type for the
confirmation step.

### Why / What / How

**Why:** [SECRT-2854](https://linear.app/autogpt/issue/SECRT-2854).
#15459 lets people delete their account from Settings or the paywall. It
**closes** the account (cancels the plan, disconnects apps, stops work
and email, ends sessions, takes listings down, writes an
`AccountDeletionRequest` with `eraseAfter` 14 days on) and the owner can
keep it until then. This PR is the erasure that follows: it deletes the
account's data, stored files included, the PostHog person and the
MailerLite subscriber, hands over workflows other people use, and
deletes the rows Marketplace `ON DELETE RESTRICT` keys block.

**What:**
- The erasure processes #15459's request rows once their 14 days are
over. For each one it claims the row, then:
  - reruns `stop_user_work` and removes the memory crons;
- sends `send_account_deleted_email` if `confirmationSentAt` is null,
then records it;
- erases the data (files, memory, PostHog, MailerLite, marketplace, the
shared-workflow handover);
  - keeps a ban record if the account is banned;
  - deletes the rows, the auth identity with the `User` row;
  - sets `completedAt` on the claimed row.
- **A periodic job** does this inside the database manager, behind
`ACCOUNT_ERASURE_SWEEP_ENABLED` (**off by default**).
- **The `erase-account` CLI** erases one account or every pending one
(dry run unless `--apply`), and `--close` closes an account for a
request made by email. `--close --now` is for an owner who asked to be
erased at once: it closes the account with no 14 days to keep it (or
ends a closed account's 14 days now) and erases it in the same run.
- **Sign-up refusal:** Better Auth's sign-up checks (email sign-up and a
first social sign-in, in `lib/auth/auth.ts`) ask the backend whether the
address has a live ban record and refuse a match with the existing
generic "Sign-up isn't available" message.

**How**

*The hand-off.* The `AccountDeletionRequest` row is the whole contract
(the docstring of `account_deletion/service.py`, whose hand-off
paragraph now says selection does not filter on `startedAt`). The row
outlives the `User` (no foreign key), so backend auth keeps refusing a
token issued before the close.

*Keep vs. erase.* Keep deletes the row only while `startedAt` is null
and `eraseAfter` is ahead; the erasure claims only once `eraseAfter` has
passed. Whichever statement commits first wins; the other matches no
row. A dry run never claims.

*The ban record.* `AccountBanRecord { emailHash @unique, category,
recordedAt, deleteAfter }`. The hash is HMAC-SHA256 of the trimmed,
lower-cased address, keyed with a new dedicated secret,
`BAN_RECORD_HASH_KEY` (backend only; the frontend never holds it). It
must never be rotated, or the records stop matching; rotating
`ENCRYPTION_KEY` no longer touches them. `make init-env` and the
single-container image generate it (runtime config v3). The category is
`ADMIN` (admins are the only source of bans today); the admin's
free-text reason is never copied. `deleteAfter` is the ban's own expiry,
or 3 years, whichever is first. The frontend asks `POST
/api/auth/email/ban-record` with a frontend service token (scope
`auth-sign-up:check`), the same pattern as the suppression lookup; the
lookup fails open (3 s timeout), like the access blocks. An admin
creating an account from /admin/users is not checked.

*The job.* `scheduler.erase_closed_accounts` is registered only when
`account_erasure_sweep_enabled` is on and checks the switch again when
it fires. It calls `start_account_erasure_sweep` on the database manager
over RPC, which starts the sweep in the background (at most one per
process). A blocked account is logged at error level, a failed step
stops that account until the next run, a skipped step (setting not
configured) is logged as a warning, and an unclaimed one (kept
meanwhile) at info.

**The database manager pod then needs** (also in the runbook): the
bucket settings and GCS credentials, FalkorDB (`GRAPHITI_FALKORDB_*`),
the PostHog personal key (`POSTHOG_PERSONAL_API_KEY`,
`POSTHOG_PROJECT_ID`, `POSTHOG_APP_HOST`), MailerLite
(`MAILERLITE_API_TOKEN`), Stripe, and `BAN_RECORD_HASH_KEY` (same value
as the API pods). A step is skipped only when its service is not
configured at all. A skipped step stops the erasure before the rows go,
and the request stays open, unless the stack lists that service in
`ACCOUNT_ERASURE_UNUSED_SERVICES`; any error fails the step the same
way. Without `BAN_RECORD_HASH_KEY` nothing is skipped: the ban record
step fails for an account with a ban in force, and that erasure stops
before the rows go; an account with no ban in force needs no record and
is erased as usual.

**Edits to #15459's files:**
- `AccountDeletionSource.EMAIL` (`schema.prisma`, migration
`20261020120000_account_deletion_source_email`), its `_SURFACES` entry
and its reason line in `notifications/auth.py`. The API's `source` is
still `settings`/`paywall`.
- `send_account_deleted_email` picks the "being erased" line once
`erase_after` has passed (optional `now` for tests); its tests now pin
the clock.
- The hand-off paragraph of `account_deletion/service.py`'s docstring
(selection not filtered on `startedAt`).
- `lib/auth/auth.ts` calls the ban record check;
`lib/auth/access-blocks.ts` exports its generic message.

**What only we do** (unchanged from before):
- **Files:** every bucket prefix of the account (private, public,
legacy), or the local data directory on a self-hosted stack. Media a
handed-over listing or another account's library entry still shows is
kept.
- **FalkorDB memory:** Otto's graph and one per expert. Skipped only
when `GRAPHITI_FALKORDB_HOST` is empty.
- **PostHog person** (`persons/bulk_delete`) and **MailerLite forget**.
- **Credit ledger** kept six years (no foreign key), metadata cut to IDs
and amounts.
- **Sole-member orgs** go with the account, their own Stripe customers'
subscriptions cancelled.
- **Shared-workflow handover** to "Deleted user": workflows other
accounts use (library, presets, runs, experts, org or team members) go
to the placeholder with all versions and sub-workflows; their listings
stay taken down.
- **Published things** (Toran, 22 Oct): off the Marketplace, keep
working for people who have them. Skill listings are deleted (installs
are copies). OAuth apps others use block the erasure
(`oauth_apps_in_use`) until moved or disabled.

### Changes 🏗️

- `backend/data/account_erasure/request.py`: `pending_user_ids` by
`eraseAfter`, `claim_request`, `complete_request` by the claimed ID,
`send_confirmation` passes `erase_after`, `close_for_email_request`
returns the erase date. `PENDING_GRACE` removed.
- `routine.py`: lease and claim before any step; `closing` removed;
`ban_record` step before `database`.
- New `lease.py` (per-account Redis lease). `storage.py`: sweeps the
local data directory whenever the private bucket is unset. `memory.py`:
off-loop client. `database.py`: preset inputs and notification events.
`shared_agents.py`: the team clause. `third_parties.py` and
`notifications/mailerlite.py`: `retry_max_attempts=3`.
- `preflight.py`: `not_due` blocker; loads `eraseAfter` and the
identity's ban. `models.py`: `request_id`, `erase_after`, `banned`,
`ban_expires`, `ErasureReport.not_claimed`.
- `work.py`: removes the memory crons (`remove_scope_memory_jobs`) for
the account and each expert.
- New `ban_record.py`: `keep_ban_record`, `is_banned_address`,
`purge_expired_ban_records`, `email_hash`.
- `sweep.py`: no grace; purges expired ban records each run; logs an
unclaimed account at info.
- `cli/erase_account.py`: `--close` only closes; erasing respects the 14
days; prints `not_claimed`.
- `schema.prisma` + migration `20261102120000_account_ban_record`:
`AccountBanRecord`, `AccountBanCategory`.
- `api/features/auth_email/routes.py`: `POST /api/auth/email/ban-record`
(`checkAuthEmailBanRecord`); `openapi.json` regenerated.
- `notifications/auth.py`: the late wording
(`ACCOUNT_BEING_ERASED_PREHEADER`).
- Frontend: new `lib/auth/ban-record.ts`, called from
`assertEmailSignUpAllowed` in `lib/auth/auth.ts`. The earlier changes to
#15459's sign-in/sign-out code are reverted.
- `docs/engineering/account-deletion.md`: the runbook.
- Unchanged from before: the package rename to `account_erasure`, the
`erase-account` CLI (no `delete-account` alias), the scheduler job and
settings, `credit.py`/`credit_admin_lookup.py`.
- `mailerlite.py`: the erasure's lookup and `forget_subscriber` use
their own client with at most three attempts; the shared client is
unchanged, so the backfills and the audience consumer still wait out
rate limits.

**User-visible copy changed:**
- Account-closed email sent by the erasure after the 14 days: "The 14
days to keep it are over, so everything in your account is being erased
now." in place of the "Sign in by <date>" line.
- Sign-up with the address of an erased, banned account: the existing
"Sign-up isn't available from this network or email address. If you
think this is a mistake, please contact support."

**Configuration changes:**
- `ACCOUNT_ERASURE_SWEEP_ENABLED` (off by default) and
`ACCOUNT_ERASURE_SWEEP_INTERVAL_SECS`, on the scheduler.
- The database manager needs the bucket, FalkorDB, PostHog personal key,
MailerLite, Stripe settings and `BAN_RECORD_HASH_KEY` before the switch
is turned on, and the REST API pods need `BAN_RECORD_HASH_KEY` too.
- New table `AccountBanRecord` (migration). No change to `.env.default`
values or `docker-compose.yml`.

**Rollout:** the switch stays off until #15459 ships; turn it on (with
the database manager settings above) before the first closed accounts
reach their 14 days, so each is erased on time (we promise 30 days).

### Still open
- [ ] Refunds (business decision; the runbook says to ask the owner
before `--close`).
- [ ] The org ledger (`OrgCreditTransaction`) of a sole-member team org
is still deleted with the org.
- [ ] Ban categories: only `ADMIN` exists today. Finer categories (spam,
fraud, ...) would need admins to pick one when banning.
- [ ] For Kris: email changes to a banned address; the org-ledger
metadata scrub; access to submission media. (Decided: a step skips only
when unconfigured, and a skip stops the erasure unless the service is
listed as unused; a dedicated ban-record key; fail-open sign-up check;
trim + lower-case normalisation.)

### Not covered (listed in the runbook)
- E2B volumes, Mem0, the Linear customer, the Ayrshare profile,
LaunchDarkly, Sentry.
- Redis keys and Langfuse traces (expire within 90 days).
- Provider grants without a revoke endpoint.

### Risks
- **Turning the job on without the database manager settings** stops
every erasure at the first unconfigured service, with an error in the
sweep's log, and the requests stay open until the setting is in. Nothing
is marked done with data left behind.
- **Rotating `BAN_RECORD_HASH_KEY`** makes existing ban records stop
matching (the runbook and `.env.default` say never to).
- **A backend outage at sign-up** lets a banned address back in (the
check fails open by design).
- **Handed-over workflows keep their content** as the creator saved it;
their credentials go with the account.
- **Very large accounts** could hit the 5-minute transaction timeout;
only executions are batched.
- **A blocked account** (sole owner of a shared org, or the developer of
an OAuth app others use) is logged at every sweep until resolved.

### Agents and large language models used
- Claude Code (Claude Opus 5.5)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] Backend unit tests (mock-only, all passing locally): pending
selection by `eraseAfter`, the claim statement (conditional, `COALESCE`,
database clock, never clears), an unclaimed request runs no step, the
claim before any step and never on a dry run, completion of the claimed
row only (`request_test.py`, `routine_test.py`); `not_due` and its date
(`preflight_test.py`); memory crons removed for the account and each
expert, an unreachable scheduler failing the step (`steps_test.py`); ban
record: keyed hash, only a ban in force, 3 years or the ban's end, dry
run, no key, lookup, purge (`ban_record_test.py`); a record purged
between the conflict and the restart is created again, a second conflict
fails the step (`ban_record_test.py`); a Redis client that can't connect
is `ErasureLeaseUnavailable` (`lease_test.py`); a failed step logs no
`exc_info` (`routine_test.py`); only the erasure's MailerLite client
bounds its retries (`mailerlite_forget_test.py`); sweep purge and info
log (`sweep_test.py`); CLI `--close` only closes, a kept account fails
the command, `--pending --apply` purges even when every account is
blocked (`erase_account_test.py`); late email wording
(`notifications/auth_test.py`); the ban-record route and its service
token (`auth_email/routes_test.py`).
- [x] Frontend (Vitest, 484 auth tests passed): the real Better Auth
handler refuses a sign-up from a recorded address with `SIGN_UP_BLOCKED`
and creates no account, and lets it in when the lookup fails
(`access-blocks-flow.test.ts`); `ban-record.test.ts` (scope, request,
generic message, fail-open). `pnpm types` and `pnpm lint` clean.
- [ ] `account_erasure_db_test.py` (DB-backed, left to CI; backend CI
does not run while this PR's base is a feature branch, so it runs once
#15459 merges and this PR retargets to `dev`): pending only after the 14
days, claimed and completed, rerun not claimed and unchanged; a preset's
saved input and a notification event deleted; claim vs. Keep on the real
table (no claim inside the 14 days, Keep wins before the claim, too late
after even on a clock inside the 14 days, a second claim keeps the first
date); a team agent handed over only with another team member; a banned
account erased leaves a findable record, a non-banned one none, and the
purge removes it.
- [x] isort and black clean; pyright clean on the changed backend files
(after `gen-prisma-stub`, as CI runs it).
- [ ] On dev, with the switch on and the database manager settings in
place: close a test account with files, a shared workflow, a schedule
and a sole-org subscription; move its `eraseAfter` into the past;
confirm the sweep erases it within the hour, `startedAt` and
`completedAt` set, buckets empty except kept media, PostHog and
MailerLite cleared, the memory crons gone.
- [ ] On dev, a banned test account erased the same way: sign-up with
its address refused; after deleting its `AccountBanRecord`, allowed.
- [ ] On dev, `erase-account <email> --close --apply` closes and prints
the date; `erase-account <email>` refuses with `not_due`.

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

No UI changes: the only frontend change is in `lib/auth` (sign-up check)
and its tests; no pixel change.

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <[email protected]>
2026-11-11 09:10:41 +00:00
Nicholas Tindle 0640de04ca feat(backend): add Claude Fable 5.2 support (#16699)
Adds `claude-fable-5-2` (Anthropic, released 2026-11-09) to the LLM
catalog, the `LLMModel` enum and the block docs.

- Price: $8/$40 per 1M (20% below Fable 5.1), cache reads $0.20/1M, 5m
cache writes $10/1M, at the standard 1.5x credit margin (1200/6000
credits). Source: Anthropic's Fable 5.2 pricing table.
- Specs: 1M native window (capped at 200K like the rest of the Claude 5
family), 128K max output, tools, JSON and reasoning.
- Anthropic documents that Fable 5.2 rejects forced `tool_choice`, so it
joins the auto-tool-choice list next to Fable 5.1.
- Fable 5.2 matches the existing `claude-fable-5` family prefix, so the
temperature and tokenizer handling already apply.
- No defaults change: Fable 5.1 was never a default alias. Opus 5.5,
Sonnet 5.5 and Haiku 5.5 remain the current defaults.
- Tests: pinned catalog rates, price card cache prices,
forced-tool-choice cases.
2026-11-11 07:06:18 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 82bf3e9201 feat(backend): set the plan tier on the PostHog person (SECRT-3411) (#16702)
### Why / What / How

**Why:** `chat_blocked` (#16698) carries a `tier` only for the cost
limits; the per-minute and running-turn caps send it as null, and
PostHog persons had no tier at all. So refusals couldn't all be split by
plan, which SECRT-3411 asks for.

**What:** the PostHog person now carries `subscription_tier`, the stored
plan tier (`NO_TIER`, `TRIAL`, `PRO`, `MAX`, `BUSINESS`, `ENTERPRISE`,
legacy `BASIC`). Any event, `chat_blocked` included, can then be broken
down by plan through the person. The refusal path is unchanged: no tier
lookup is added there.

**How:** the tier rides on the existing lifecycle sync
(`backend/data/posthog_lifecycle_sync.py`). It already reads the user
row, runs in the background, never raises, and coordinates syncs per
user so the newest read is the one sent. What changes is who schedules
it, and one extra column in the daily sweep. The name follows the
tracking plan, which calls the plan tier `subscription_tier` on every
event and keeps the person's billing properties under `subscription_*`.

### Changes 🏗️

- `posthog_lifecycle.py`: `LifecycleUser` and `LifecycleSnapshot` get
`subscription_tier`; `person_update()` `$set`s it. A tier the caller
didn't read is neither sent nor unset (MailerLite's backfill builds
users without it). The status still never reads the tier.
- `posthog_lifecycle_sync.py`: the per-user sync passes the row's tier;
the daily sweep's query selects it. New `set_tier_on_sign_in()` for
sign-ins (below).
- Tier writes now schedule the sync:
- `credit.set_subscription_tier`: cancel without a Stripe subscription,
a plan changed in place, the lazy reconcile. A new
`track_lifecycle=False` keyword is used by the webhook path, which
already decides for itself, and by the 6-hourly tier sweep.
  - `rate_limit.set_user_tier` (the admin's tier override).
- The 6-hourly tier sweep schedules nothing, as before. One person sync
per corrected user would fan out a Stripe call each when a run corrects
many at once. Every user it touches has a Stripe customer, so the daily
lifecycle sweep brings their persons up to date.
- An account deletion's cancel still syncs nothing. A sync would bring
back the person the erasure deletes.
- Sign-in (`POST /api/auth/user`, existing accounts): `$set` of the
stored tier. Accounts the daily sweep doesn't cover (no Stripe customer,
no trial) get it without waiting for a tier change, and a sync PostHog
never got is repaired. A tier change landing during a sign-in can be
overwritten by the older read until the next sign-in or sync, which is
documented.
- Impersonation: the sign-in `$set` goes through
`posthog_client.capture`, which drops `$set` while an admin acts as the
user. The sync itself still runs then, as before: it mirrors the stored
tier, trial and Stripe state, not the admin's action.
- Fire-and-forget throughout: syncs run in background tasks, and the
sign-in fill is wrapped, so a PostHog failure never reaches a tier write
or a request.
- `docs/engineering/tracking-plan.md`: `subscription_tier` under Person
properties (values, when it is set, that it means the plan the limits
apply rather than payment, and that a person property shows the current
plan, not the plan at the time of the event), plus a pointer from
`chat_blocked.tier`.
- Tests: the mapping per status, the sync and sweep payloads, the
sign-in `$set` (empty tiers skipped, dropped while impersonated, PostHog
failing), every scheduling hook (tier write, admin override,
account-deletion cancel on both paths, the webhook-internal and sweep
writes, the unchanged sweep), and a tier write surviving a failing sync
with the real scheduler. The old tests that pinned "a tier write
schedules no sync" are rewritten.

No configuration changes. Existing accounts get the property at the
daily sweep (accounts with billing history), their next sign-in, or
their next tier change. `scripts/backfill_posthog_lifecycle.py --send
--all-users` sets it on everyone at once.

### Agents and large language models used

- Claude Code, Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `posthog_lifecycle_test`, `posthog_lifecycle_sync_test`,
`posthog_lifecycle_hooks_test`, `posthog_lifecycle_trial_cancel_test`,
`test_backfill_posthog_lifecycle`, `credit_account_deletion_test`,
`v1_stripe_webhook_test` (without its `server` test),
`stripe_reconciliation_test`, `stripe_activation_sweep_test` and the
five changed `credit_subscription_test` cases pass locally (mock-only)
- [ ] CI: the rest of `credit_subscription_test`, `user/routes_test` and
the database-backed suites
- [ ] After merge, on dev: change the QA account's tier and check its
PostHog person shows `subscription_tier`; a `chat_blocked` insight
breaks down by that person property

Linear: SECRT-3411

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-11 04:58:18 +00:00
83f6ce5776 feat(platform): add in-app feedback prompts (#16348)
### Why / What / How

**Why:** The app collects almost no feedback at the moments that matter.
Today there is the Tally form in the account menu, thumbs on Otto
replies and a Sentry "Report Error". Nothing asks why someone cancels a
trial, leaves checkout, or found a finished task disappointing.

**What:** Six feedback prompts, live in the app with no feature flag,
reshaped after the PM review (trial checkout and brain dump skip prompts
cut, trial cancel back to one dialog). This PR started as Storybook
prototypes and now wires them in, saving every answer through our
backend.

**How:** One `FeedbackPrompt` table records each showing, and an atomic
`claim` applies the limits, so they hold across tabs and devices.
Page-level prompts claim, then save each tap, and the backend forwards
to PostHog. The browser PostHog client sends nothing until someone
accepts analytics cookies, so answers never go that way. Two new Otto
tools claim on the model's behalf and render chat cards, whose ratings
use the existing reply-feedback path.

A prompt never blocks what someone came to do: if saving fails, the
trial still cancels, onboarding still moves on, and no toast appears.

Linear: SECRT-3217

### Changes 🏗️

| Prompt | Where it shows | Limit | Where the answer goes |
|---|---|---|---|
| Trial cancel | Inside the one "Cancel your trial?" dialog: optional
reason chips, Keep trial and Cancel trial always shown | None | Database
+ PostHog, and Stripe `cancellation_details` |
| Left upgrade checkout | On return to Billing. Only "My card didn't go
through" offers "Try another card", which reopens the same checkout |
None | Database + PostHog |
| Work check | Under the reply that finished the work, once the turn
ends, in Otto and expert chats | Shared wait | `$ai_metric`, as a
`work_check` score |
| Otto mistake | Inside the reply when Otto decides it got something
wrong; no card when the user already said what went wrong | Once per
chat | `$ai_metric`, as an `otto_mistake` score, plus
`otto_mistake_flagged` |
| Top-up | Inside the out-of-credits dialog, after "Not now" | Every 30
days, plus the shared wait | Database + PostHog |
| Day 14 NPS | Any page, from day 14, for paying accounts that finished
a workflow run or an Otto chat that week | Once, plus the shared wait |
Database + PostHog |

**Shared wait:** work check, top-up and NPS share one 7-day wait, and
two "Not now"s in a row among them mean 30 days quiet. One advisory lock
covers all three, so two tabs cannot both pass it.

**Backend**
- New `FeedbackPrompt` table and migration; a nullable `reason` column
on `ChatMessageFeedback`.
- `POST /api/feedback-prompts/claim`, `PATCH
/api/feedback-prompts/{id}`, `GET /api/feedback-prompts/{id}`.
- `data/feedback_prompts.py`: every query is scoped by `userId`, and
another user's row returns 404.
- New Otto tools `request_work_check` and `flag_mistake`, eager so they
can render cards, and available in expert chats. `flag_mistake` takes an
optional `reason` from the shared list; with one, no card asks again
(the tool schema budget rises by 150 characters for it). Work checks are
off on chat platforms, automation and delegated sessions. Mistake flags
still reach PostHog there, with no card.
- `POST /api/credits/trial/cancel` takes an optional `{reason, comment}`
and passes it to Stripe as `cancellation_details` (`too_expensive`,
`low_quality`, `missing_features`, `unused`, `other`).
- New PostHog events: `feedback_prompt_shown`,
`feedback_prompt_answered`, `otto_mistake_flagged`. An admin acting as a
user creates no rows or events.

**Frontend**
- Prompts in `organisms/FeedbackPrompts/` take real callbacks. Options
now have fixed values, so code no longer compares labels.
- Mounted in the trial dialog, Billing, the top-up dialog, the chat and
a new `PlatformShell`, which keeps an open NPS card alive across route
changes.
- The billing cancel URL now carries `plan` and `cycle`, so "Try another
card" can reopen the same checkout.
- Chat rating requests run in order per prompt and mark the card
answered only after the rating saves. Delayed-write and failed-request
tests cover both paths.
- Review fixes: scores follow each prompt kind; unattended turns do not
claim weekly work checks; mistake analytics omit free-form chat content;
pending microphone starts cannot replace skip reasons; checkout prompts
record dismissal and close on account changes; notes stop at 2,000
characters; cancellation attempts claim separate showings.
- Polish pass: no pixel change. Checked the JSX and className diff:
styles and layout are unchanged. The fixes change behavior, form limits,
link destinations, generic tool labels, tests and generated docs.
- Pixels: the PM review changes the look of the trial cancel dialog (one
step), the out-of-credits dialog (a "Not now" button and a question
step) and the NPS card (two rows of scores under 640px). Screenshots
still to attach.

### Agents and large language models used

- Claude Code with Claude Opus 5.5
- Codex with GPT-6

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] `black`, `isort`, `ruff` and `pyright` on the changed backend
files
  - [x] `prettier`, `oxlint` and `eslint` on the changed frontend files
- [x] 424 targeted frontend tests across 19 files pass locally (one
worker)
- [x] Backend route/tool registry: 25 tests pass; feedback
route/tool/rating checks: 105 tests pass locally
- [x] Required backend architecture and block tests: 4,175 pass, 84
known skips
- [x] Full backend and E2E CI suites pass on `39d1ac5619`; repo review
approved
- [x] Review fixes (`6d9fdd7637`): 79 feedback route/chat tests pass;
required backend architecture/block sweep passes 4,175 tests with 84
known skips (local ClamAV disabled); frontend format/lint/types and
targeted Python format/lint/types pass. Full CI is running.
  - [ ] Each prompt checked by hand in a running app

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm format`, `pnpm lint` and `pnpm types` pass locally,
including the `design-system/*` rules; I did not raise any count in
`scripts/eslint-rules/design-system-baseline.json` except for a moved,
renamed or split file (explained under **Changes**)
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [ ] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked
- [x] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: GPT-6 (Codex) <[email protected]>
2026-11-10 15:15:59 +00:00
Nicholas TindleandClaude Opus 5.5 25e1ea300c feat(platform): Continue with Microsoft, with the Microsoft 365 offer as a movable onboarding step (#16269)
> Builds on #16268, which merged into `dev` on 2026-10-30. This PR now
targets `dev`, and its diff is only its own changes.

### Why / What / How

**Why:** people whose work runs on Microsoft 365 had no way to sign in
with Microsoft, and connecting their mail and files came later as a
separate step most never found. The onboarding wizard also had a fixed
order, so trying the connection offer earlier or later meant a code
change.

**What:**

- **Continue with Microsoft** on the login and signup pages. It asks
Microsoft only for name and email (`openid profile email`, no Graph
permission).
- A new account's onboarding asks **"Also connect Microsoft 365?"**
using the panel from #16268. Skipping it is fine.
- The wizard's order is data, so an experiment can move the step without
a code change.

**How:**

- **Who signs in** (`lib/auth/microsoft-login.ts`,
`microsoft-sign-up-policy.ts`): Better Auth keys accounts on Microsoft's
object ID (`oid`), never the email address. An address counts only when
Microsoft vouches for it: a personal account's own Outlook.com, Hotmail,
Live or MSN mailbox, or a work address its organization verified
(`xms_edov`). A personal Microsoft account can carry any address and
keeps it after its owner loses the mailbox, and a work address without
`xms_edov` is whatever an admin typed in. A guest's address is never
trusted either: a personal account invited into someone else's
organization signs in with that organization's tenant, whose `xms_edov`
would vouch for it. A token counts as a member's only when its `idp` is
absent or exactly the issuing tenant's issuer. Any other address neither
joins an existing account nor creates one. Both answer Better Auth's
`account_not_linked`, the same redirect to the byte, so a refusal
doesn't reveal whether an AutoGPT account uses the address. /login gives
one message for both and points to email sign-up. Emailing a
confirmation link would not be safe: the mailbox's owner could confirm
an account that the other person's Microsoft login still opens.
- **Team addresses** (`microsoft-sign-up-policy.ts`): an AutoGPT team
address (`@agpt.co`) gets an account only through Google, however
Microsoft vouches for it. A refused one lands on
`/login?provider=microsoft&error=team_email_requires_google` with its
own message. A team member's existing account still signs in with
Microsoft. Microsoft's checks run first in the user-create hook, so its
sign-ups get these codes. #16445 applies the team-address and
verified-email rules to every provider; whichever of the two PRs merges
second keeps that hook order (Microsoft's check, then the team-address
check, then the verified-email check), and a trial merge of both passes
their sign-up tests together.
- **Only the redirect flow signs in**: Better Auth's ID-token sign-in
(`POST /sign-in/social` with `idToken`) is switched off for Microsoft,
because it creates accounts without the callback's checks; the sign-up
check also follows the provider rather than the callback path.
- **Refusals**: a Microsoft sign-up the signup gate refuses shows the
"Email Not Allowed" dialog on /login. Better Auth sends a refusal from
our checks back with its code as `error` (`SIGN_UP_NOT_ALLOWED` for the
gate, which `isWaitlistSignInError` reads). A blocked network, a
disposable address or one mail can't reach say the sign-up isn't allowed
instead of "try again", and a blocked sign-in says so too. The page
never shows the URL's own text. A refusal keeps the page's `next`, so a
deep link still opens after logging in another way. Since #15459 every
provider's refusal comes back to /login; Microsoft's also says
`provider=microsoft`, and only then does /login use the Microsoft
wording.
- **The access step** (`onboarding/steps/AccessStep/`): it shows when
the account signed in with a provider whose data Otto can use.
`accessProviders.tsx` maps each provider to its connection, so Google or
GitHub can join with one entry. `GET /api/auth/sign-in-providers` names
the account's sign-in providers, looked up once per account; Better
Auth's own `/list-accounts` stays disabled because it returns account
details.
- **Movable order** (`onboarding/store.ts`): `DEFAULT_STEP_ORDER` lists
the steps. The `onboarding-step-order` flag's payload names the steps to
lead with; the rest keep their default order, and `preparing` stays
last. With payments on, the access step always follows the subscription
step, whatever the experiment asks: a new account has no plan until
then, and connecting a service needs one. The order is frozen per
account when the wizard loads, through `useFrozenPerUser`, which the
payment snapshot now uses too (dev's ref read during render raised 14
`react-hooks/refs` warnings). Progress is saved by step name, so a
resumed wizard lands on the right step even if the order changed.

### Changes 🏗️

- Frontend auth: the Microsoft login provider (shown only when
`AUTH_MICROSOFT_CLIENT_ID`, `AUTH_MICROSOFT_CLIENT_SECRET` and
`NEXT_PUBLIC_AUTH_MICROSOFT_ENABLED` are set), the sign-up refusal for
unconfirmed addresses, error messages on /login, and Microsoft sign-ups
counted as such in analytics.
- Onboarding: the access step, `resolveStepOrder` (with the paywall
rule) and `buildStepLayout` with an `order`, `useFrozenPerUser`, and
`access` as a saved step name (backend `WizardStep`).
- Backend: the `onboarding-step-order` payload flag, and a seeded e2e
user who signed up with Microsoft.
- From CodeRabbit's and Sentry's reviews, once this targeted `dev`:
  - Login and signup start one provider sign-in at a time.
- The sign-in-provider lookup gives up 5 seconds after it starts, its
retry included, so it holds onboarding for at most 5 seconds. A quick
failure gets one retry in what's left of that time; a lookup that timed
out isn't retried.
- A saved draft keeps the role pick once the person moves past the role
step, in whatever order the wizard used. It reads that order from
`completedSteps`. With an experiment that asks the role first, drafts
saved on the steps after it used to leave the pick out until
`painPoints`.
- A regression from #16268: the Copilot executor again reports a refused
Microsoft refresh as `microsoft_365_copilot_credential_unavailable`, not
`…_not_found`. Since #16268 the refused refresh raised
`MicrosoftAuthError`, a `ValueError` subclass.
- With Better Auth 1.7.12 (#16419) merged from `dev`:
- The Microsoft refusals carry codes (`account_not_linked`,
`team_email_requires_google`). 1.7's OAuth callback only redirects an
error that has one; any other ends on a raw 403 JSON page.
  - /login explains the codes dev's own sign-up checks now send.
- Accounts are keyed on `oid`, as 1.7 does, instead of `${tid}:${oid}`.
Dev and prod have no Microsoft login rows yet, so nothing needs
migrating.
- The Playwright spec signs in through the API. The login form can be
replaced just after it loads, which dropped the email the spec typed
(smoke failed once on CI).
- From Pwuts's review:
- An address Microsoft didn't confirm gets `account_not_linked` with no
message, as described above.
- Sign-in no longer fetches the Microsoft profile photo, so it reads
only name and email.
- The Outlook.com domains are the 113 whose primary MX is Outlook.com's
consumer servers. That adds `hotmail.se`, `live.se` and the other
country domains, and drops `outlook.co.uk` (its mail goes to a third
party) and `hotmail.nl`, `hotmail.cz` and `outlook.cz` (a Microsoft 365
organization first).
- Both sign-in buttons' logos are decorative (`alt=""`), so a screen
reader says each provider once.
- `openapi.json` regenerated; `.secrets.baseline` line numbers updated.
- `docs/engineering/microsoft-local-e2e.md`: sign-in, the access step,
moving it, and the extra Entra settings.

**Deploying:** this builds on #16268, whose Entra step 1 (token version
2, then personal accounts in the sign-in audience) has to happen before
this release reaches an environment. The Entra app also needs the
`{frontend}/api/auth/callback/microsoft` redirect and the `xms_edov`
optional ID-token claim. Without the claim, work and school accounts
can't use Continue with Microsoft. Dev is configured; prod is not.

### Agents and large language models used

- Claude Code with Claude Opus 5.5: design, implementation, tests and
live testing.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `lib/auth/__tests__/microsoft-sign-in-flow.test.ts` runs Better
Auth's real handler with Microsoft's endpoints stubbed: an Outlook.com
personal account signs up; a verified work address joins the account
that uses it; a personal account's other address, an unverified work
address and a guest's address are kept out of it; an unconfirmed address
creates nothing, with email verification on or off; an ID token for an
Outlook.com address, signed with a test key served as Microsoft's key
set, can't sign in directly; and a waitlist refusal comes back as a code
/login recognizes. Before the fixes, the guest case joined the existing
account and the ID token created one; with `disableIdTokenSignIn`
removed, the ID token still signs in.
- [x] `vitest` over onboarding, login, signup, `lib/auth`, the Microsoft
panel and feature flags: 935 passed, including the access step offering
only "Continue" once connected and following the paywall when payments
are on. `pnpm types` and `pnpm lint` pass.
- [x] Backend: `pytest backend/data/onboarding_wizard_test.py
backend/util/feature_flag test/test_e2e_seed_credentials.py`: 284
passed.
- [x] After #16268 merged, with `dev` merged in (only the shared
engineering doc conflicted; this PR's copy, which extends #16268's, was
kept): backend 674 passed (onboarding wizard, feature flags, Microsoft
integrations, Copilot transports), black, isort and pyright clean, and
`openapi.json` matches the export. Frontend `pnpm types` and `pnpm lint`
pass, and all four `vitest` shards pass under `TZ=UTC` (12,700 tests).
- [x] The team-address rule: `microsoft-sign-in-flow.test.ts` drives the
real callback for an `@agpt.co` work address (refused, nothing created)
and for a team member's existing account (signed in), and the login page
test shows the message.
- [x] Screenshots, before (dev-builder) and after (this branch) at 1440
and 390 px, of /login and /signup with the Microsoft button, /login
after a refused sign-in, and the access step:
https://github.com/Significant-Gravitas/AutoGPT/pull/16269#issuecomment-6560941949
- [x] Playwright `microsoft-sign-in-happy-path.spec.ts`: a seeded
Microsoft sign-up opens onboarding on the offer, and "Not now" moves on.
It passed locally against a full stack and in CI's smoke run on 7ff101a.
  - [x] After #16419 (Better Auth 1.7.12) merged, with `dev` merged in:
- `microsoft-sign-in-flow.test.ts` on 1.7: before the fix, the
unconfirmed-address and team-address refusals got no redirect, and
accounts were keyed on `oid` alone.
- With the fix, `vitest` over `lib/auth`, login, signup, onboarding,
`api/auth`, `components/auth` and the auth callback: 1,249 passed.
- `pnpm types`, `pnpm lint` and `pnpm format` pass, detect-secrets
passes on the changed files, and backend ruff, isort and black are
clean.
  - [x] Live, on a local stack with the dev Entra app:
- [x] A work account that signed in with Microsoft lands on "Also
connect Microsoft 365?", connects through Microsoft's consent screen,
sees each service's status, and "Continue" moves the wizard on.
- [x] A personal account whose address belongs to an existing AutoGPT
account is refused: a personal Microsoft account on
[email protected] went through Microsoft's consent and came back
to /login with "An AutoGPT account already uses that email. Log in the
way you usually do.", and no Microsoft login was linked to that account.
Under the old rule it would have joined it.

- [x] After #16459 (payment as the `ENABLE_PLATFORM_PAYMENT` setting)
and #15853 (PostHog-only flags) merged, with `dev` merged in on
2026-11-03:
- Conflicts in `useOnboardingLayout.ts`, its test and the default mocks.
The step placement freezes on its own inputs (the step-order flag and
the sign-in lookup), and only the paywall rule follows #16459's payment
rule: off until a read succeeds, so a failed read that later succeeds
moves the access step behind the paywall and nothing else moves (Sentry
caught an earlier version that left the whole placement live). Tests
cover both cases, and breaking any of the four rules turns a test red.
- All four `vitest` shards under `TZ=UTC`: 1,185 files passed. `pnpm
types`, `pnpm lint` and `pnpm format` pass, and `openapi.json` matches
the export.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api` plus the paywall-route, architecture and tool-schema
guards and this PR's suites, 6,085 passed. The 18 failures all come from
the local setup and are in code this PR doesn't touch: no ClamAV for
dev's fail-closed virus scanner (17) and a one-node Redis cluster (1).
CI has both.

- [x] After #16577 (Better Auth 1.7.16, the Nov 3 security release) and
#16511 (one role token set for colours) merged, with `dev` merged in on
2026-11-04:
    - The only conflict was the `.secrets.baseline` timestamp.
- The Microsoft sign-in flow tests, which run Better Auth's real
handler, pass on 1.7.16. This PR's 32 frontend files use none of the
colour names #16511 removed, and `pnpm lint` passes with its new rules.
- `vitest` over `lib/auth`, login, signup, onboarding, `api/auth`,
`components/auth`, the auth callback and the Microsoft panel: 1,318
passed, and `pnpm types` passes. Both ran on the first merge
(`ad22a2d`); nine more `dev` commits merged cleanly after that, sharing
only `openapi.json` with this PR. CI checks the final head, and
`openapi.json` matches the export there.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api` plus the paywall-route, architecture and tool-schema
guards, the Copilot executor and background-subtask tests, and this PR's
suites. 6,501 passed; the same 18 failures as before all come from the
local setup (no ClamAV, a one-node Redis cluster).

- [x] After SECRT-3384 (new sign-ups go to onboarding, not Home) merged,
with `dev` merged in again on 2026-11-04:
- The conflicts were only the login and signup hooks' imports: dev
replaced `sanitizeAuthNext` with `getAuthDeepLink`, which the bodies
already used. The Microsoft sign-in passes the callback the same
filtered link, so a new Microsoft account also lands on onboarding,
where the Microsoft 365 step is.
- `vitest` over `lib/auth`, `auth-redirect`, login, signup, onboarding,
`api/auth`, `components/auth`, the auth callback, `PaywallGate`,
`TopUpPrompt`, the onboarding provider and the Microsoft panel: 1,474
passed, including dev's new onboarding-first tests that render the login
page with the Microsoft button. `pnpm types` and `pnpm lint` pass.
- Backend, against private Postgres, Redis and RabbitMQ: all of
`backend/api`, dev's changed guards (architecture, tool schema, the new
secrets guard), the Copilot executor tests and this PR's suites. 6,675
passed; the same 18 failures as before come from the local setup (no
ClamAV, a one-node Redis cluster).

- [x] After #16629 (the SECRT-3384 follow-up) merged, with `dev` merged
in on 2026-11-05:
- #16629 sends OAuth round trips the whole sanitized `next` (`safeNext`,
tracking parameters included) and lets `/auth/callback` decide. The
merge keeps `providerCallbackPath` and passes it `safeNext`, so Google's
callback URL is the one #16629 builds (its exact-string tests pass) and
Microsoft's adds `provider=microsoft` for sign-up attribution.
- `vitest` over the same areas plus #16629's new onboarding-provider
tests: 1,537 passed. `pnpm types` and `pnpm lint` pass.
- Backend: all of `backend/api`, the guards and the Copilot executor
tests (which cover dev's two `processor.py` changes beside this PR's
error code): 6,798 passed; the same 18 local-setup failures.

- [x] After #16308 (plan-first trial, hire step removed) merged, with
`dev` merged in on 2026-11-06:
- #16308 retires the hire step and shows the plan on its own when there
is no paywall. The merge takes `hire` out of `DEFAULT_STEP_ORDER`, so an
experiment can't bring it back, and shows the plan step wherever #16308
does. The access step still comes first by default and still follows the
paywall when payments are on. The longest layout is still 8 steps:
self-hosted, with the plan, connect and access steps.
- `vitest` over the same areas: 1,662 passed, all 45 onboarding files
included. `pnpm types` and `pnpm lint` pass, and `openapi.json` matches
the export.
- Backend: all of `backend/api` (dev's new onboarding plan tests
included), the guards and the Copilot executor tests: 7,120 passed; the
same 18 local-setup failures.

- [x] Review round (`c4a9807d78`), then `dev` merged in again on
2026-11-09 (`4b9c1ca07d`, 21 commits, no conflicts):
- Continue with Microsoft asks for `openid profile email` only: with the
profile photo off nothing calls Graph, so `User.Read` only stored an
unused Graph token. A refused Microsoft sign-in returns to `/login` with
the page's sanitized `next`. The access step's sign-in lookup is cached
per account, so a tab that switches account looks again. The tracking
plan lists `access` and says the step order varies. Each new test fails
with its fix undone.
- `vitest` over the same areas: 1,723 passed. `pnpm types` and `pnpm
lint` pass, and `openapi.json` matches the export.
- Backend: all of `backend/api`, every architecture guard (dev added
one), the Copilot executor tests, dev's OAuth refresh-failure suites
(#15081) beside the Microsoft integration suites, and this PR's suites:
7,414 passed; the same 18 local-setup failures.

- [x] After #15459 (self-serve account deletion) merged, with `dev`
merged in on 2026-11-10 (`ed4d09a856`):
- #15459 sends every provider's refused sign-in to /login and shows its
own notice for a banned account. The merge keeps one error page for
every provider (the login page with the sanitized `next`), marks
Microsoft's with `provider=microsoft`, and shows the Microsoft copy and
the "Email Not Allowed" dialog only for those, so a Google refusal never
mentions Microsoft and other providers' codes show nothing, as on dev.
#15459's banned notice comes before the Microsoft copy. Both new flags
are kept. New tests cover a Google refusal and a banned Microsoft
account.
- `vitest` over the same areas plus the feature flags: 1,829 passed.
`pnpm types` and `pnpm lint` pass, and `openapi.json` matches the
export.
- Backend, the same set: 7,632 passed; the same 18 local-setup failures.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): `AUTH_MICROSOFT_CLIENT_ID`,
`AUTH_MICROSOFT_CLIENT_SECRET` and `NEXT_PUBLIC_AUTH_MICROSOFT_ENABLED`
in `frontend/.env.default`, commented out; the Entra app changes are
under **Deploying**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-10 08:11:26 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 30b70e7679 feat(backend): send a PostHog event whenever a usage limit refuses or pauses work (SECRT-3411) (#16698)
### Why / What / How

**Why:** when a usage limit refuses or pauses someone's work, PostHog
hears nothing, so we can't see how often limits bite, for whom, or where
(SECRT-3411).

**What:** one backend event, `chat_blocked`, every time a limit refuses,
pauses or queues work, from every surface. The name and its `reason`
values (`usage_limit`, `rate_limit`) come from the analytics plan; the
event now also fires for scheduled, bot, block, voice, API and
background refusals, not just the chat.

**How:** the event is sent where each refusal is answered, not where the
limit is read. `check_rate_limit()` is also used by probe-only checks
(for example `at_reset`), and only the caller knows the surface.
`RateLimitExceeded`, `TurnRefusal`, `ConcurrentTurnLimitError` and
`InflightCapExceeded` now carry the reading (tier, used, cap), so each
call site is one line. Sending goes through `posthog_client.capture`
(queued on the client's background thread), and every helper is guarded
end to end, reading the refusal included, so a refusal returns exactly
what it returned before, just as fast, even when PostHog raises. Server
events have no consent check, as for the other backend events.

### Changes 🏗️

- `backend/copilot/limit_tracking.py` (new): `track_chat_blocked` and
helpers for cost limits, turn caps, turn pauses and queue/scheduler
refusals; the surface of an executor turn is worked out from its origin.
- Properties: `reason`, `limit` (`daily`, `weekly`, `trial`, `task`,
`rate`, `concurrency`), `outcome` (`refused`, `paused`, `queued`),
`surface` (`chat`, `schedule`, `chat_bot`, `block`, `voice`, `api`,
`background`), `tier`, `used` and `cap` (dollars for cost limits, counts
for rate/concurrency), `resets_at` (null for a trial, a window that
allows nothing, and the turn caps). `distinct_id` is the user id. No
message content or personal data.
- Where it fires:
- Chat: the stream route's cost check (`enforce_platform_allowance`,
also used by Keep going), the concurrency 429s and the queue fallback
(`queued`), the queued-message per-minute cap, and the Keep going
concurrency 429.
  - Chat bots: the usage gate and the in-flight cap.
- Voice (`/speech`), external API/MCP tool calls, and the nightly memory
pass (weekly).
- Scheduler: usage refusals and the turn cap, and "Send/Resume at reset"
turns a used-up window holds back (`queued` to the next reset, `refused`
when nothing refills) or the turn cap refuses.
- Queue promotion: a queued turn a usage window holds back (`queued`),
checked once per pass over the queue.
- Executor: the pre-turn allowance gate (`refused`), plus every pause
the engine streams: a landed `daily`/`weekly`/`trial`/`task` pause
(`paused`) and a refused auto-continue (`refused`). A `steps` check-in
is not a limit and sends nothing.
- Sub-sessions and the AutoPilot block at the turn caps, approval wakes,
`message_session` wakes, and per-call platform spend (copilot tools,
dry-run blocks, the simulator).
- Readings: a pause the engine streams keeps the reading its check made
(on the stream part as a private attribute, never serialised to the
client or the stored marker), and a turn that lands on its budget
carries its plan tier. Used and cap stay null for a mid-turn landing.
- Surface: checks deep inside a turn (a tool's platform spend) report
the surface the executor resolved, so a chat bot's turn is `chat_bot`
and unattended work `background` there too.
- An ended trial reads as `trial` (cap 0) and a plan with no allowance
as `weekly` (cap 0) at every site, through one helper.
- A turn the executor refuses before it starts is marked `impersonated`
when an admin started it.
- Each backend process sends at most one `chat_blocked` a minute per
user, limit, outcome and surface, so a client retrying a refused request
sends one event, not one per retry. The window is in memory: it never
blocks or fails the refusal.
- The queued-turn event carries the queued turn's own impersonation
flag, stored when it was queued, not that of the turn whose end ran the
queue.
- `PostHogEvent.CHAT_BLOCKED` added and pinned;
`docs/engineering/tracking-plan.md` lists the event and drops it from
"not sent yet".
- A `chat_blocked_events` test fixture in `backend/conftest.py`, and
tests at each refusal point that check the properties sent.

Out of scope: `/usage/reset` 429s and the generic
`api/utils/rate_limit.py` limiter for non-chat routes (not usage limits
on work). Also left out: outages that leave the limits unreadable
(503s), paywall/ban/Codex-access refusals (not usage limits), the
engine-switch continuation dispatch and the AutoPilot block's recovery
re-enqueue (best-effort system retries).

Post-merge checks:
- [ ] Build a PostHog insight on `chat_blocked`, broken down by `limit`,
`surface` and `outcome`.
- [ ] Compare one day of Sentry 429s on `POST
/api/chat/sessions/{id}/stream` with `chat_blocked` where `surface =
chat` and `limit` is `rate`, `concurrency` or a cost limit.

### Agents and large language models used

- Claude Code, Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Unit tests for `limit_tracking` (properties, reason mapping,
refill rules, surfaces, never raises, even on a refusal it can't read)
- [x] A refusal answers the same when PostHog raises, at real call sites
(MCP tool call, executor gate)
- [x] Event assertions at each refusal point: chat route, Keep going,
queued messages, chat bots, voice, MCP, dream, scheduler and at-reset
turns, queue promotion, executor gate and streamed pauses,
sub-sessions/AutoPilot block, approval wakes, `message_session`,
platform spend
- [x] Existing suites for every touched module pass locally (DB-backed
tests left to CI)
  - [x] pyright, ruff, black and isort clean on the changed files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-10 07:09:45 +00:00
9d04ac1a8d feat(platform): self-serve account deletion with an optional exit question (#15459)
### Why / What / How

**Why.** Today the only way to delete an AutoGPT account is to email
[email protected], and people without a plan can't reach Settings at all
(the paywall covers `/settings/*`). SECRT-2877 asks for self-serve
deletion from Settings › Account and from the paywall, with an optional
"Why are you leaving?" step.

**What.** A two-step Delete account dialog: the optional exit question
(each reason offers one alternative, such as Manage connected apps or
cancelling a trial), then a confirmation where the person types their
email. Confirming closes the account at once. The data is erased 14 days
later; until then the owner can sign in and choose **Keep my account**.

Erasing the data is #15487 (SECRT-2854), which also holds the ban record
that outlives erasure. The `self-serve-account-deletion` flag stays off
until that erasure runs in prod.

**How.** `POST /api/account/deletion` (`account_deletion/service.py`):

1. Check the typed email (trimmed, any case), else 400. Already closed:
200, nothing else runs.
2. Stripe: expire open Checkouts, cancel every subscription now with no
invoice or proration, turn off auto top-up. A Stripe error returns 502
and the account stays open.
3. Best effort: disconnect and revoke every connected app (10 at a time,
10 s each), revoke API keys and OAuth grants, remove schedules and the
Briefing, turn off webhook triggers, unlink chat platforms, cancel
AutoPilot turns, stop runs, turn off product email, opt out of
MailerLite.
4. One transaction: `AccountDeletionRequest` with `eraseAfter` = now +
14 days, all sessions deleted, Marketplace listings soft-deleted,
reviews deleted, share links off, Discord/Telegram/Slack links removed.
5. Email "Your AutoGPT account is closed" ("Sign in by {date} and choose
Keep my account"), then one anonymous count.

While closed, every API route answers 401 "This account is closed."
(optional-auth routes treat it as signed out; WebSocket and external API
refuse it). `is_user_refused` gates all background work: scheduler,
memory crons, graph runs, AutoPilot, webhook ingress, Briefing. The
closed state is cached 30 s and dropped on close and keep. The creator
page is hidden. Closing does not ban; a banned person can still delete,
but can't sign in to keep.

Keeping: sign-in lands on `/account-closed` with **Keep my account** and
**Sign out**. `POST /api/account/deletion/restore` deletes the request
only while `startedAt` and `completedAt` are null and `eraseAfter` is
ahead, in one statement (409 otherwise); #15487 claims a row by setting
`startedAt` first. Only the account comes back: plan, apps, keys,
schedules, triggers, share links, listings and reviews stay off, and
product email stays off until switched on.

Feedback (`POST /api/account/deletion/feedback`) goes to PostHog through
the new `capture_anonymous()`: no user id, email or IP; text capped at
1,000 characters, never logged; 20 calls per user per hour. The dialog
is `ph-no-capture`.

### Changes 🏗️

Backend
- `api/features/account_deletion/`: routes, service, step modules,
`restore.py`.
- `data/account_deletion.py`: close, reopen, request record.
- `autogpt_libs/auth/closed_accounts.py`, `dependencies.py`: closed
check in every auth dependency; `get_user_id_allowing_closed`.
- `data/user.py`: `is_user_refused`; no billing or marketing email to a
closed account.
- Refusal gates in `executor/`, `copilot/`, `integrations/router.py`,
`api/ws_api.py`, `api/external/middleware.py`, `platform_linking/`.
- `data/credit.py`, `data/subscription_checkout.py`,
`notifications/lifecycle.py`: immediate cancel tagged
`autogpt:account_deleted`, with no "ended" emails or MailerLite moves.
- `integrations/router.py`: `delete_and_revoke_credentials` extracted,
route unchanged.
- `integrations/oauth/google.py`: revoke sends the refresh token (all
Google disconnects).
- `notifications/auth.py`: `account_deleted` email.
- `api/features/store/`: hidden creator page, cache clear.
- `data/auth/`: bulk revoke for API keys and OAuth.
- `util/posthog_*`: `capture_anonymous()`, five events.

Frontend
- `components/organisms/DeleteAccountFlow/`: dialog, steps, panels,
stories, tests.
- `DeleteAccountCard` (Settings), `DeleteAccountButton` (paywall).
- `app/(no-navbar)/account-closed/`: the keep page;
`lib/auth/closed-account.ts` routes closed accounts there.
- `QuestionOptionList`: `editable` prop; `Dialog`: opt-in drawer
`autoFocus`.
- `services/billing/subscription-plan.ts`: shared with
`useYourPlanCard`.
- `openapi.json`; `docs/engineering/tracking-plan.md`.

**Configuration changes**
- Flag `self-serve-account-deletion`, off by default (`FORCE_FLAG_` /
`NEXT_PUBLIC_FORCE_FLAG_` overrides). Off: no entry points, delete and
feedback 404. Status and restore stay reachable so nobody is stranded.
- Migrations `20261010120000_account_deletion_request`,
`20261030120000_account_deletion_grace_period`.
- Routes: `POST`/`GET /api/account/deletion`, `POST .../restore`, `POST
.../feedback`. Not behind the paywall; 403 for an admin impersonating
(except status).
- Protected page `/account-closed`.
- Proxy: 180 s timeout for `POST api/account/deletion`.
- CI: E2E forces the flag on; smoke count 28 → 29.
- No new secrets or env vars.

**Known gaps**
- Cloudflare's ~100 s origin timeout is under the 180 s proxy timeout:
many apps can show a gateway error; a retry returns 200.
- Open Stripe invoices stay open, with collection stopped.
- `opened` shares the 20/hour feedback cap with answers.
- A schedule fire or webhook delivery already under way can still add
one run or turn.
- Deploy the DB manager with or before the chat bot (new `open_only`
lookups).

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] Backend, frontend and `autogpt_libs` tests pass in CI on the
current head.
- [x] Playwright: delete from Settings, sign in, `/account-closed`, Keep
my account, back to Settings.
- [x] Dialog at 320×568 to desktop, 200% and 400% zoom: no sideways
scroll, footer reachable, focus never hidden.
- [ ] Manual delete from Settings and paywall, then Keep, on the current
head.
- [ ] `/account-closed` at the same sizes; iOS Safari and Android
Chrome.
- [ ] Dev deploy with the flag on: plan cancelled with no invoice,
Google grant gone, schedule gone, email arrives, PostHog events have no
person, a double call sends one email.

#### Screenshots

[The screenshot page](https://claude.ai/artifact/M4jqDhdBQvgfCAdbEFExD7)
has the dialog at all seven sizes, both entry points, and the
keyboard-focus checks.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

Resolves SECRT-2877

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Krzysztof Czerwinski <[email protected]>
2026-11-10 06:39:32 +00:00
Nicholas Tindle 76e4d5ce58 feat(backend): add Meta Muse Ember (#16700)
Adds `meta/muse-ember` (Meta, released 2026-11-09) via OpenRouter,
alongside the other Muse models.

- Price: $0.10/$0.50 per 1M, cache read $0.004/1M, from the live
OpenRouter listing, at the standard 1.5x credit margin (15/75 credits).
OpenRouter bills it by cost, so the per-1M credits are display only.
- Specs: 1M context, 943K max output, tools, structured outputs and
reasoning.
- Tests: pinned catalog rates; block docs updated.
2026-11-10 06:34:49 +00:00
e6a3d35514 feat(platform/onboarding): Otto replies on the brain dump's not-enough screen (#16654)
### Why / What / How

**Why:** A large share of onboarding brain dump recordings land on the
"We didn't catch enough of that" screen, and most people there skip or
leave instead of talking again. Rejected takes are tiny (mic checks,
greetings, filler), and today's screen reads like an error: it never
shows what was heard, and its headline sounds like the microphone
failed. Context:
[SECRT-3361](https://linear.app/autogpt/issue/SECRT-3361).

**What:** The screen becomes a short, labeled chat ("Otto replies"):
what Otto heard, his answer, and one big, easy question, "What do you
want off your plate?", with an example for the role. Talk stays the main
action, and talking or writing afterwards keeps the question in view.
The backend now sends back what was heard on an `insufficient_content`
reject, and known speech-to-text silence phrases become
`no_usable_speech` for voice, so the screen never shows words nobody
said.

**How:** The rejection fills the existing `transcript_preview` field;
the only API addition is the optional `otto_reply_kind` enum (9e8d14e).
On the frontend a small `useInsufficientReply` hook holds what was
heard, the take's mode and `isFollowUp`, and the screen's buttons reuse
`handleRestart`, `showTyping` and `handleSkip`. Otto never moves (the
design system allows a blink only); the typing dots and the fade are
skipped with reduced motion, and Otto's text is in the DOM from the
first render.

### Changes 🏗️

- **Backend** (`onboarding_dump`): `insufficient_content` rejects return
`transcript_preview` (stripped, at most 280 characters) for voice and
typed; `no_usable_speech` never does. New `stt_artifacts.py` holds a
documented `STT_ARTIFACT_PHRASES` set ("thank you", "thanks for
watching", "please subscribe", "bye bye", "you", "music", "applause",
…), matched after lowercasing and stripping punctuation. A voice take
made of nothing but these phrases, one or several ("Thank you. Bye."),
is `no_usable_speech` with no judge call; a take with any other word
still goes to the judge, and typed text always does. Thresholds, prompts
and error codes are unchanged.
- **Otto's line fits what was said** (9e8d14e): a fixed set of written
lines, one per kind of too-short take (mic check, greeting, unsure,
reluctant, wants to skip and so on). On an `insufficient_content`
reject, Jev (TypeSafe) picks the kind and returns it as
`otto_reply_kind` on `FinalizeResponse`, a typed enum. No model text is
shown. No pick, any Jev failure, or `other` (no kind fits) gives the
default line. The quality gate itself is unchanged.
`brain_dump_insufficient_shown` gains `reply_kind`. The lines and fit
checks are in the comment below.
- **Not-enough screen**: `InsufficientState` rebuilt from `YourMessage`,
`OttoReply`, `TypingDots` and `InsufficientActions`, with the copy in
`insufficient-copy.ts`. No `StepHeader` headline on this screen.
- **After it**: the typing headline is the big question with "A few
sentences is plenty." and the placeholder `Like: {example}` (new
optional `placeholder` prop on `TypingComposer`/`TypedFallback`); the
recording screen shows a compact Otto bubble above the timer
(`OttoQuestionCard`).
- **Prompts**: `offYourPlateExample` per role and
`offYourPlateExampleForRole`, falling back to Other.
- **Tracking**: new `brain_dump_insufficient_shown` (`mode`,
`error_code`, `heard`, `reply_kind`, `prompt_set`) for voice and typed;
`from: "insufficient"` on `brain_dump_restarted`,
`brain_dump_typed_fallback` and `brain_dump_skipped` from this screen;
`follow_up: true` on `brain_dump_completed` after it. Existing events
keep their names and properties. Tracking plan and the live-name test
updated.
- **Tests**: new `InsufficientState.test.tsx`; new cases in
`useBrainDumpStep.test.ts`, `prompts.test.ts`, the onboarding page test
(follow-ups, tab order, events), `quality_test.py` and
`service_test.py`. The old "Thank you for watching." judge test now
covers a non-exact outro.
- **Merged with `dev`** (plan-first trial, #16308): one conflict in
`useBrainDumpStep.ts`, resolved by keeping both sides.

### Screenshots

Today's screen first, then this branch. Each image is 1440 on the left
and 390 on the right, captured from the real page against a mocked
backend.

| | 1440 and 390 |
| --- | --- |
| **Before** (today's screen) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/01-before.png"
width="640" alt="Before, today's screen"> |
| Words heard | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/02-words-heard.png"
width="640" alt="Words heard"> |
| No words | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/03-no-words.png"
width="640" alt="No words"> |
| Typed, too little | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/04-typed.png"
width="640" alt="Typed, too little"> |
| Mic blocked | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/05-mic-blocked.png"
width="640" alt="Mic blocked"> |
| Otto typing (first 900 ms) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/06-otto-typing.png"
width="640" alt="Otto typing, first 900 ms"> |
| Recording after the screen | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/07-recording-after.png"
width="640" alt="Recording after the screen"> |
| Typing after the screen | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/08-typing-after.png"
width="640" alt="Typing after the screen"> |
| 1280x720, no scrolling (words heard, recording after) | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/5ad1164/pr-screenshots/secrt-3361-pairs/09-1280x720.png"
width="640" alt="Words heard and recording after the screen at
1280x720"> |

Every case was checked at 1440x900, 1280x720, 390x844 and 320x640: no
sideways scroll anywhere, and no vertical scroll at 1280x720. At 320x640
the screen scrolls a little, like the rest of the step. Re-captured
after merging `dev`: the screens are unchanged. The images live only in
commit 5ad1164; the next commit removes them, so nothing reaches `dev`.

### Event walk

Captured from the browser console with PostHog debug mode on
(`ph_debug`, what `posthog.debug()` sets), walking the real page against
a mocked backend. PostHog's own `$` properties are left out. Recorded
before 9e8d14e, so `brain_dump_insufficient_shown` below has no
`reply_kind` yet.

```text
voice reject, Talk, Send
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  transcription_failed           {"error_code":"insufficient_content","finalize_latency_ms":4}
  brain_dump_insufficient_shown  {"mode":"voice","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_restarted           {"from":"insufficient"}
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  brain_dump_completed           {"duration_secs":4,"input_mode":"voice","finalize_latency_ms":4,"prompt_set":"Founder/CEO","prompts_seen":1,"follow_up":true}

voice reject, Write, I'm done
  brain_dump_started             {"prompt_set":"Founder/CEO"}
  transcription_failed           {"error_code":"insufficient_content","finalize_latency_ms":4}
  brain_dump_insufficient_shown  {"mode":"voice","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_typed_fallback      {"reason":"chose_to_type","prompt_set":"Founder/CEO","from":"insufficient"}
  brain_dump_completed           {"input_mode":"typed","chars":30,"prompt_set":"Founder/CEO","prompts_seen":1,"follow_up":true}

typed reject, Continue without it
  brain_dump_typed_fallback      {"reason":"chose_to_type","prompt_set":"Founder/CEO"}
  brain_dump_insufficient_shown  {"mode":"typed","error_code":"insufficient_content","heard":true,"prompt_set":"Founder/CEO"}
  brain_dump_skipped             {"prompt_set":"Founder/CEO","prompts_seen":1,"from":"insufficient"}
```

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Frontend: `pnpm format`, `pnpm lint`, `pnpm types` clean after
merging `dev`; the onboarding and analytics test files pass (70 files).
Before the merge, the full `pnpm test:unit` run passed apart from the
live event-name pin test, fixed by adding the new name
- [x] Backend after merging `dev`: `black`/`isort` and `pyright` clean
on the changed files; non-database `onboarding_dump` tests (778 at
44c4525, after merging `dev` again) and `architecture_test.py` pass.
`test_block.py` passes apart from 13 blocks that need ClamAV or outbound
SMTP, which this sandbox lacks (no block changes here). The
`*_db_test.py` files need the Docker test stack, so CI runs them; none
of them reach the changed code
- [x] Each absence test was checked against the mutation it guards
(preview sent on every reject, artifact match on typed text, substring
match, no artifact check, skip not ending a follow-up) and fails under
it
- [x] Each case (words heard, no words, typed, mic blocked), and the
recording and typing screens after it, at 1440x900, 1280x720, 390x844
and 320x640: no sideways scroll at any size, and 1280x720 fits without
scrolling in every case
  - [x] Event walk with PostHog debug on (above)
- [x] Keyboard: Back → Skip for now → Talk → Write → Continue without
it. Accessibility tree: Otto's label, first line and question are in an
`alert` while the dots show; the dots are `aria-hidden`
- [ ] Gate eval (`brain_dump_gate_eval.py`): not run, as it needs live
speech-to-text and judge API keys this environment does not have. Worth
running before merge.

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json`
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours; colours use role tokens (`bg-surface`, `bg-accent-surface`,
`border-brand/15`, `bg-brand/20`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`
- [x] No change to `src/components/**`, `globals.css` or `tailwind*.ts`;
screenshots of the changed screen are in the section above
- [x] User-facing copy says Otto, never agent or graph

Resolves SECRT-3361

---
_Generated by [Claude
Code](https://claude.ai/code/session_01G58r43gKL1t2whqRCQXASw)_

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Toran Bruce Richards <[email protected]>
2026-11-09 20:39:34 +00:00
Bently c27bf0b975 docs: add deployment environment variables guide (#12122)
## Summary
Documents all environment variables that must be configured when
deploying AutoGPT to a new server or environment.

## Changes
- Added `docs/platform/deployment-environment-variables.md`: what to
change to run the Docker Compose stack on a server that people reach
from other machines, and why.
- Listed it in `docs/platform/SUMMARY.md`.
- Brought up to date with `dev` (merged, not rebased):
- Dropped the step that made Web Push (VAPID) keys by hand. `make
init-env` generates the pair since #16416, and the frontend fetches the
public key from the backend. Only `VAPID_CLAIM_EMAIL` is left to set.
The steps after it are renumbered.
- Added `TRUSTED_PROXIES` (#16522) to the proxy section and the
checklist, for a chain of proxies or one that adds to `X-Forwarded-For`.
- `TRUST_X_FORWARDED_FOR` also feeds the per-IP sign-in and
password-reset attempt limits, and the guide now says so.
- Added `BETTER_AUTH_INTERNAL_URL=http://localhost:3000`, found by
deploying the guide for real (see Testing). Without it, the frontend's
server fetches a signed-in visitor's token through the public hostname.
Sign-up then fails without an error whenever the container can't resolve
that name or trust its certificate.
- Said which lines have to be added because they aren't in the `.env`
files yet (`BACKEND_CORS_ALLOW_ORIGINS`, `BETTER_AUTH_INTERNAL_URL`).
- Gave the no-proxy API example its `/api` path
(`NEXT_PUBLIC_AGPT_SERVER_URL=http://<server>:8006/api`).

## What's Documented
- Which `.env` file each setting lives in, and which values the compose
files override
- What `make init-env` generates, and what breaks without each secret
- The public URLs to set, with `BETTER_AUTH_INTERNAL_URL` for the
frontend's calls to itself
- A TLS reverse proxy (Caddy example), the ports to close,
`TRUSTED_PROXY_COUNT`, `TRUST_X_FORWARDED_FOR` and `TRUSTED_PROXIES`
- Limiting sign-ups, and the mailer that email verification and password
reset need
- OAuth callback URLs for integrations and for social sign-in
- Using your own database or Redis
- A checklist

## Closes
- Closes #10961
- Closes OPEN-2715

## Testing
Documentation only, no code changes.
- Followed the guide from the top on a clean checkout on a LAN test
machine. Set only what it says, behind Caddy with TLS on two hostnames,
and used a browser on another machine.
- Sign-up, log-in, a server-rendered page while logged in, the builder,
and an agent run (completed, output shown) all work. Live updates
connect over `wss://`.
- The sign-in history records the visitor's real address, and a spoofed
`X-Forwarded-For` is dropped.
- Before `BETTER_AUTH_INTERNAL_URL` was added, sign-up failed
(`ENOTFOUND` on the public hostname inside the frontend container).
- `python -m unittest discover -s .github/workflows/scripts`: 101 tests
OK, including `test_docs_layout.py` (8).

<!-- greptile_comment -->

<details><summary><h3>Greptile Summary</h3></summary>

Adds comprehensive deployment environment variables guide documenting
all configuration settings required for deploying AutoGPT to new
environments. The guide includes a quick reference table of critical
URLs, security key generation commands, service configuration details,
OAuth provider setup, and a complete deployment checklist. Documentation
is well-organized with clear sections for URL configuration, security
keys, database setup, service dependencies, default ports, OAuth
callbacks, optional services, and Docker vs external services guidance.
</details>


<details><summary><h3>Confidence Score: 5/5</h3></summary>

- This PR is safe to merge with minimal risk
- Documentation-only PR with comprehensive and accurate environment
variable reference. Cross-verified all variables against actual
`.env.default` files, confirmed file structure matches codebase, and
validated all internal documentation links. Only one minor default value
discrepancy found.
- No files require special attention
</details>


<sub>Last reviewed commit: 8d557d3</sub>

<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-11-09 12:14:42 +00:00
bba2ff341a feat(platform/onboarding): plan-first trial: starter tasks and experts on the paywall, hire step removed, team hired on trial start, Home starts with ranked tasks owned by experts (#16308)
### Why / What / How

**Why.** Since Oct 16, 954 people signed up, 444 saw the paywall, 209
opened Stripe and 29 started a trial
([funnel](https://eu.posthog.com/project/117338/insights/ugcxrNQU)). 68%
of the people who reached the hire step hired nobody, and only 3 people
have ever clicked a suggested first task on Home. The hire step asked
people to pick experts before they had seen what those experts would do,
the paywall then asked for a card without a reason, and Home's
suggestions had no owner and no order.

**What.** The paywall becomes the plan: what Otto heard, the first three
tasks ranked, each owned by the expert who will do it, beside the 7-day
trial. The hire step is gone. The plan's team is hired on the preparing
screen once the trial is confirmed, so a user who abandons Stripe hires
nobody. Home opens on the same tasks as a list: "Start here" on the
first open row, each row naming who takes it, x removes a task, and Hide
/ Show collapses the list.

**How.**
- **Backend.** After the greeting and the team are stored, one TypeSafe
Jev call ranks the greeting's tasks and picks an owner for each (a call
to TypeSafe's hosted model, which carries the tasks, the team and up to
6,000 characters of the transcript); a deterministic fallback keeps the
greeting's order and matches owners by icon and title. The team is
capped to the three people the step shows. `GET
/onboarding/brain-dump/plan` serves the plan (or a role plan for a
skipped, failed or empty brain dump), `POST
/onboarding/brain-dump/plan/settle` makes the plan the step shows the
take's plan when the step stops waiting, and `POST
/onboarding/brain-dump/plan/hire` hires its team idempotently. A
suggested task's `expert_id` is read fresh: an open task's from the
owner's active hire, a started task's from the chat it opened.
- **Onboarding.** `PlanTrialStep` (`PlanPanel` beside `TrialPanel`, or
`PaidPanel` without a trial offer, or the plan alone with Continue when
there is no paywall) replaces the hire step and the trial welcome.
`usePlanHire` hires on the preparing screen after the trial (or plan) is
confirmed.
- **Home.** `OnboardingIntroCard` goes back to `SuggestedTaskList`; the
#16236 cards are deleted. A task is sent to its owner the way the
recipient picker sends to an expert, so its chat is the expert's.

### Progress

- [x] **Phase 0**: branch, baseline counts, before screenshots. Frontend
`vitest` 12,282 passed + 4 expected failures (1,127 files); backend
`onboarding_dump` + `experts` 904 passed, 29 skipped.
- [x] **Phase 1** `bd3dc3cc feat(backend/onboarding): plan with ranked,
owned tasks and a hire endpoint` (+ `f879fb20` review follow-ups).
- [x] **Phase 2** `ba232616 feat(frontend/onboarding): plan + trial step
replaces hire and subscription` (+ `7fb3dce4`, the plan read waits for a
Skip still in flight).
- [x] **Phase 3** `61708f3c feat(frontend/onboarding): hire the plan's
team after the trial is confirmed`.
- [x] **Phase 4** `130b92ad feat(frontend/home): suggested tasks ranked
and owned by experts, list restored, remove and collapse` (+ `3782dfc4`,
a started task names the expert its chat is with).
- [x] **Phase 5** `e075809d` tracking audit fixes and the tracking plan,
`177f4fad` the Playwright helper, `044c4fdb` edge-case coverage (a Jev
outage logs one warning, an unset key warns once at startup, an empty
team hires nobody, the walkthrough lands on the pill's row), `6eda537b`
focus after Remove never drops to the page (found by the final review),
`8badd23e` one ref write out of render (a lint warning this PR added),
`d113cf2c` the plan's hire checks the paywall and the plan read is
listed open (CI's `paywall_routes_test` requires one or the other for
every route).
- [x] **The plan step's wait** (found testing the preview) `9532c2b7`
the step stops waiting on work the plan does not use and a plan it gives
up on is the plan, `2abf8821` the settle route listed open while
paywalled, `418ae59c` review fixes. See "The plan step's wait" below.
- [x] **Merged with dev again** `8d81826a`, for dev's expert-roster test
update (#16443). Checked on the merge: the exported API schema matches
`openapi.json`; frontend types and lint pass, and onboarding, Home's
task list and analytics run 1,053 tests, all passing; backend `experts`
+ `onboarding_dump` + `paywall_routes_test` has 1,170 passed, and the
one failure needs ClamAV, which this environment does not run.
- [x] **Kept current with dev** `075f17a9` (checked: schema matches
`openapi.json`, frontend types and lint pass, 506 test files / 6,510
tests pass in onboarding, copilot, services and hooks, backend
`onboarding_dump` + `paywall_routes_test` 637 passed), `538606ae`,
`28e82949`, `6a8e6052`, `21ec4514`, `89750735`, `28a7f663`, `9a7fa159`,
and `c365e277` (a port of #16489, dev's fix for the MCP tests it had
broken). `9a7fa159` took dev's colour-role refactor (#16511): the
hire-step Playwright fixture it recoloured stays deleted, and its
`ds-colour-exempt.json` entry for `OttoCover.tsx`, a file this branch
deletes, is removed. Test-only ports for CI flakes outside this PR:
`52a5f637` (#16449, the PostHog consent test's capture interval) and
`36527ca2` (#16566, the shared auth mock's session exports; #16565, the
external API concurrency test's Redis timeout). They no-op once dev
carries those PRs. The conflicts: dev's Langfuse prompt client in
`intro.py` (#16026); a storage key both sides added (#16527); dev's
Text, Icon and Button props on the Suggested Tasks components this
branch rewrote (#16513: kept this branch's versions, moved two colour
classes to `tone`, regenerated the design-system baseline); dev's
`ENABLE_PLATFORM_PAYMENT` setting (#16459); and dev's removal of unused
exports (#16163), which dropped `SELF_HOST_STEPS` from the onboarding
store while this branch had changed it (`075f17a9`: kept this branch's
plan step in `NO_PAYWALL_STEPS` and left `SELF_HOST_STEPS` deleted,
since nothing imports it). Dev's palette removal (#16612) also added a
lint-rule test case that relies on the exemption for `OttoCover.tsx`,
which this branch deletes; `000a3f88` points that case at
`CodeRenderer.tsx`'s allow-list instead. The paywall test's PostHog mock
now returns the event, as dev's own tests do since #16518.
- Since #16459, the no-paywall version of the plan step is what an
install gets while `ENABLE_PLATFORM_PAYMENT` is off, which is the
default. The plan step is step 5 either way; only the paywall beside it
changes.
- Checked on the last merge: the exported API schema matches
`openapi.json`; frontend types and lint pass, and onboarding, copilot,
services, hooks and the paywall gate run 485 test files (6,442 tests),
all passing; backend `onboarding_dump`, `experts`, `paywall_routes_test`
and dev's payment-setting and trial tests: 1,326 passed, and the one
failure needs ClamAV.
- [x] **The greeting has a fast backup, so the brain dump shapes the
plan** (found testing the preview) `2c416169`, with review fixes in
`a50305f3`. After a real brain dump, the plan step kept showing the
role's starter tasks. The greeting, which writes the plan's tasks,
either failed (the generic greeting) or was still running when the step
stopped waiting at 30 s. Sonnet writes long answers for a long take, and
this PR turned its thinking off, which is proven on Haiku 5.5 but not on
Sonnet 5.5. `greeting_race.py` runs Sonnet first, and Haiku 5.5, which
the team job already calls the same way, joins it. Haiku starts at once
when Sonnet fails or gives an unusable answer, or alongside Sonnet once
it has run 12 s. The first full answer wins and the other call is
cancelled. Each attempt logs its model and why it failed, and the winner
logs its model and time. Sonnet is now asked for by OpenRouter's own id,
`anthropic/claude-sonnet-5.5`. A two-lens adversarial review confirmed
three defects, now fixed in `a50305f3`: a malformed reply could escape
the race and fail the dump, a test passed for the wrong reason, and the
env example was stale. Checked: `onboarding_dump` +
`paywall_routes_test`, 654 passed.
- [x] **Every plan starts with a team of three** (asked for after
testing the preview) `d6ea5524`. Without a brain dump, the team came
from the wizard role alone: two experts for Sales/BD, Operations and
HR/People, none for Other. Each role now names three skills that cover
its starter tasks. Sales/BD adds Quinn for the account research;
Operations and HR/People add Kai, for the self-chasing tracker and the
interview scheduling. A brain-dump team with fewer than three is topped
up with the wizard's picks after the model's own. Checked:
`onboarding_dump`, `experts` and `paywall_routes_test`, 1,255 passed.
- [x] **Tasks go to the experts whose roles fit them** (found testing
the preview) `29476fbb`, with review fixes in `676a6b34`. A product
manager's plan named Alex, Quinn and Nadia as the team and every task as
Otto's. The owner match compared exact role strings ("research",
"bookkeeping") with the catalog's roles, which now read "Research, Data
& KPI Analysis", "Market & Competitor Intelligence" and "Product", so
nobody matched. Now a role's skills come from the words it contains
(`role_skills.py`), and the role sets, the title and icon hints and the
fallback team all name skills. Against the live roster, every role's
starter plan is its team's work: Product gets Alex, Alex, Nadia, Quinn,
Alex. The one exception is Engineering's customer-questions task, which
no engineering expert covers. Among teammates with a skill, the most
focused one takes the task, so Maya (Marketing) writes the newsletter,
not Clip (Motion Graphics). Title hints read the task's subject, not a
word in passing: a "feature" in a marketing title or a sales "pitch" no
longer decides. New hints cover CRM, contracts, security, bookkeeping
and fundraising work. A task whose best match is cut from the
three-person team goes to the next match who stays, not to Otto. An
empty seat goes to a later task's owner before someone with no task. A
four-lens adversarial review checked this against the live roster, with
each finding verified. The greeting's failure log now names the
exception type, because a timeout's message is empty.
- Checked: the onboarding suite (613 tests) passes; experts +
`paywall_routes_test` pass (638 passed, 30 skipped); pyright is clean on
the changed package. The new `role_skills_test.py` pins owners and teams
against the catalog's 33 roles. See "The greeting fell back on the
preview" below.
- [x] **Review round (kcze)** `4015926a`, then merged with dev
`6c365f60`. 18 of the 19 findings fixed, each with a test that failed
first; `d286bd1f` fixes the 19th, with `e438b230` and `4ea3cc78`
correcting that commit:
- Settling keeps the tasks the greeting already wrote instead of
swapping in the role plan, and the role plan's template read is cached
for 60 s.
- `plan_experts_hired` moved to the backend and counts only experts that
joined in that call.
- Preparing retries a hire that got no answer inside its 15 s window,
toasts who couldn't join, refreshes Home's task owners, and waits
(bounded) for a ready plan when none was shown.
- The plan step bounds and retries its settle, keeps polling behind a
fallback the server never stored, ignores stale answers, and remembers
only stored plans.
  - Checkout is back to "within a trial usage allowance".
- Home holds owned tasks while the roster read fails, doesn't carry a
new-chat mode over to another recipient, and only restores a recipient
it changed.
- `d286bd1f`, `e438b230` and `4ea3cc78` then cover the last finding and
two gaps. `hire_plan` refuses a plan that `settle_plan` could not settle
(409, nobody hired, no event) and the client retries it inside its
window. This replaces the earlier choice to hire anyway: a user with
nobody is now the cost only if every retry loses too, and then the toast
says so. A refresh mid-hire re-asks once the first call's time is up,
with its own retry window: the answer does the hire if the first call
was lost, says who did not join, and refreshes Home. If no answer comes,
it shows no toast, because the first call may have worked. Home's owners
refresh whatever the answer. A hire nobody could join is not tried
again, because the answer can't tell a blip from the expert limit.
`plan_experts_hired` counts a failure once, through a Redis claim that
gives up after 1 s and reports anyway.
- Left as is: when no answer ever comes on the first hire, the toast
names the whole plan even though a lost answer may have hired them
(rare, and Home's roster still refreshes). A refresh whose first call
was lost and whose re-ask never gets an answer leaves nobody hired and
shows no toast; only Sentry sees it (rarer still). Failed whole calls (a
409 or 5xx) send no `plan_experts_hired`, as the tracking plan says.
- Checked on the merge: the exported API schema matches `openapi.json`;
frontend types and lint pass; backend `onboarding_dump`, experts routes
and db, and `posthog_events` tests pass (1,025 passed, 19 skipped);
frontend Vitest runs 1,220 files (13,685 passed, 4 expected failures).
- [x] **Review round (Capy, CodeRabbit)** `32bac59b`, after merging dev
in `0d7379cf`. Each of the 31 findings was checked against the code: 8
are fixed, and the other 23 are answered on the PR with the reason each
stays as is.
- Home's suggested-task reads fail when the roster can't be read and a
task names its owner, instead of handing every owned task to Otto. The
plan read and the plan hire keep the lenient read.
- Each plan-hire try gets a 7 s deadline while another try still fits in
the 15 s window, so a stalled request is sent again. The last try waits
for its answer; the proxy's 30 s timeout ends it with the failure toast.
- The plan step's settle retries stop once the step is left, so they
can't land on a newer take. Paid checkout stays locked while the trial
is starting.
- Removing the last task on a touch screen moves focus to a composer
button, not the text box that opens the keyboard. The folded list is
inert while it closes. A roster read still running doesn't count against
the task-switch give-up.
  - Checked: CI green on `32bac59b` (project coverage 90.61%, +0.04%).
- [x] **Merged with dev** `88fefd94` (the conflicts were dev's
design-token and Tailwind 4 refactors on files this branch deletes or
rewrote) and `ef8ccdb6` (this branch's components on dev's design-system
lint; see "Where this differs").

Final counts:

| | Before (dev, Phase 0) | After (`6eda537b`) | After the dev merge
(`ef8ccdb6`) |
| --- | --- | --- | --- |
| Frontend `pnpm test:unit` | 12,282 passed + 4 expected failures, 1,127
files | 12,471 passed + 4 expected failures, 1,135 files | 12,834 passed
+ 4 expected failures, 1,164 files |
| Backend `onboarding_dump` + `experts` | 904 passed, 29 skipped | 1,073
passed, 29 skipped | 1,172 passed, 29 skipped (with
`paywall_routes_test` and dev's `onboarding_role` and
`onboarding_wizard` tests) |

The deleted hire-step, card and trial-welcome tests are inside those
numbers; the count went up by 189 regardless. `8badd23e` (after the full
run) moves one ref write into an effect; the 622 onboarding tests pass
on it. `d113cf2c` adds one backend test; `onboarding_dump` +
`paywall_routes_test`: 526 passed.

### Before and after

All screenshots, side by side, desktop (1440 × 900) and phone (390 ×
844): **[Plan-first trial: before and
after](https://claude.ai/artifact/N4aZGHKWh3Ug3upSVKNA83)** (a private
Claude page: it opens for people it has been shared with). It covers:

| Screen | Before (dev) | After (this PR) |
| --- | --- | --- |
| The step after the brain dump | Hire step, then the trial welcome |
One plan + trial step (brain dump and skipped; paid and no-paywall
variants; pending skeleton; 1280 × 720 and 1366 × 768 without scrolling;
320 × 640 with the sticky trial button) |
| Preparing | Checklist | Checklist with "Hiring Maria, Max and Mina" |
| Home under the greeting | Two-column cards | The ranked, owned list
with the Your team row |
| Home after the greeting | Hide all | Hide / Show, "{n} more tasks", a
started task, the "Task removed" toast |
| The walkthrough's last step | Lands on the first card | Lands on the
row with the Start here pill |

The screenshots come from `next dev` with every API call mocked (no
backend, no Stripe here). The app renders light only (`forcedTheme`), so
there are no dark captures.

### Changes 🏗️

**Backend (`onboarding_dump`)**
- `plan.py`: one Jev call ranks the greeting's tasks and picks owners;
every failure (no key, timeout, raise, cut state, malformed answer)
returns None and the plan falls back. `plan_owners.py`: the
deterministic fallback, the team cap of three, and owners matched by
skill among the three the plan keeps. `role_skills.py`: a template's
skills, read from the words of its role, so a renamed role still
matches. `plan_job.py`: the last step of the background pipeline, a
conditional write on `updatedAt`, retried, Jev asked once.
`plan_service.py`, `plan_models.py`, routes: `GET /plan` (open while
paywalled: it is shown on the paywall step), `POST /plan/hire` (checks
the paywall, so a user who left Stripe without paying hires nobody,
whatever the client sends).
- `role_tasks.py` / `role_task_sets.py`: five starter tasks per wizard
role, each naming the skills that take it, owned from the role's
fallback team. `recommend_experts.py`: the fallback team is the roster's
most focused template for each skill, starting with the role's first
skill, then the user's pain points, then the role's other skills. Each
role's skills cover its starter tasks.
- `SuggestedPrompt` gains `expert_template_id`, `why`, `expert_name`;
`SuggestedTask` gains read-time `expert_id`.
`suggested_tasks.with_expert_ids` reads a started task's expert from its
chat (`get_chat_session_expert_ids`), so firing that expert, or hiring
the owner after Otto took the task, does not relabel the chat.
`expert_hired` gains `via: plan`.

**Onboarding (frontend)**
- `SubscriptionStep/PlanTrialStep.tsx` with `PlanPanel` (polls every
second and shows the server's progress as a checklist, settles the plan
at 30 s, never swaps a shown plan, restores it on a Stripe cancel
return), `TrialPanel`, `PaidPanel`. The trial date math moved to
`TrialPanel/helpers.ts` with its tests. Deleted: `HireStep/`,
`SubscriptionStep.tsx` and the trial welcome components (#16036) the new
step does not use.
- `store.ts` drops the hire step; saved progress that names it resumes
on the plan step.
- `usePlanHire.ts` + `usePreparingStep.ts`: one hire after confirmation,
retried inside its window (sessionStorage guard against a remount),
"Hiring {names}" or "Setting up your team" in the checklist, the bar
holds up to 15 s and then goes on, roster queries refreshed.

**Home (frontend)**
- `SuggestedTaskList` + `components/TaskRow`, `TaskOwner`,
`YourTeamRow`; `SuggestedTasksBlock` with the Hide / Show toggle
(`useCollapsedSuggestions`, per user in `localStorage` under
`home.suggestedTasks.collapsed`); `useSuggestedTasks` split into
`useSuggestedTaskList` and `useTaskRecipient`. Deleted:
`SuggestedTaskCard.tsx`, `SuggestedTaskCards.tsx`.
- Sending: the composer's recipient becomes the owner (`?expertId=`) and
the task goes out once the page can send; a switch that does not land in
3 s gives up with a failure toast and puts the recipient back. An owner
who is not on the team: Otto takes it, with "{Name} isn't on your team,
so Otto has this one." only when the roster actually loaded.
- Accessibility: rows are named "Start with {Name}: {title}" / "Open the
chat with {Name}: {title}" and described by their pill and why; a
starting task is announced; focus moves to the neighbouring row on
remove and back to the row when a start fails; inset focus rings; text
and icons at AA contrast.

**Tracking, docs, tests**
- `posthog-events.ts`, `plan-analytics.ts`, `experts-analytics.ts`,
`expert-thread-source.ts`; `docs/engineering/tracking-plan.md` updated
(below).
- `src/playwright/utils/onboarding.ts`: `completeOnboardingWizard` waits
for `plan-panel` to lose `plan-panel-pending`, presses Continue, then
connect or preparing.
- Two new optional settings, both commented out in `.env.default`:
`BRAIN_DUMP_GREETING_BACKUP_MODEL` (the greeting's fast backup) and
`BRAIN_DUMP_PLAN_JEV_MODEL` (the Jev model; unset means the SDK
default). No new feature flag, no data migration. `openapi.json` is
regenerated (the generated client is gitignored).

### Tracking

Both walks ran in the mocked app with every `posthog.capture()` call
recorded (twice each, identical). App events only; `$autocapture` and
`$pageleave` are left out. **The walk ran before `e075809d`**, which
renamed two properties after the audit (see "Where this differs"); the
lists show the names the code sends now, and the unit tests pin them.

<details><summary>Path A: brain dump typed (20 app events)</summary>

| # | Event | Properties | When |
| --- | --- | --- | --- |
| 1 | `onboarding_step_viewed` | `step: team` | /onboarding |
| 2 | `onboarding_step_viewed` | `step: autopilot` | Next |
| 3 | `onboarding_step_viewed` | `step: role` | Next |
| 4 | `onboarding_step_viewed` | `step: pain_points` | role Next |
| 5 | `brain_dump_typed_fallback` | `reason: chose_to_type, prompt_set:
Founder/CEO` | Write |
| 6 | `brain_dump_completed` | `input_mode: typed, chars: 283,
prompt_set: Founder/CEO, prompts_seen: 1` | I'm done |
| 7 | `paywall_viewed` | `surface: onboarding` | plan step opens |
| 8 | `onboarding_step_viewed` | `step: subscription` | plan step opens
|
| 9 | `plan_viewed` | `plan_source: llm, skipped_dump: false, tasks: 3,
experts: 3, wait_ms: 84, gave_up: false` | plan step opens |
| 10 | `trial_offer_viewed` | `trial_offer_version: trial-v1,
subscription_tier: PRO, trial_duration_days: 7, surface: onboarding` |
plan step opens |
| 11 | `onboarding_step_viewed` | `step: preparing` | Start my free
trial → Stripe → back |
| 12 | `plan_experts_hired` (backend) | `hired: 3, already_on_team: 0,
failed: 0` | preparing |
| 13 | `intro_path` | `path: A` | Home |
| 14 | `home_suggestion_clicked` | `position: 0, surface: intro,
remaining: 5, owner: expert, expert_template_id: tpl-maria, rank: 0` |
task 1 |
| 15 | `intro_followup_sent` | `chars: 115` | task 1 |
| 16 | `expert_thread_created` | `expert_id: exp-maria, thread_source:
suggested_task` | task 1 |
| 17 | `home_viewed` | — | back to Home |
| 18 | `home_suggestion_dismissed` | `position: 1, surface: home,
remaining: 4, scope: one` | x on task 2 (Undo sends nothing) |
| 19 | `home_suggestions_collapsed` | `open: 5` | Hide |
| 20 | `home_suggestions_expanded` | `open: 5` | Show |
</details>

<details><summary>Path B: brain dump skipped (19 app events)</summary>

| # | Event | Properties | When |
| --- | --- | --- | --- |
| 1–4 | `onboarding_step_viewed` | `step: team`, `autopilot`, `role`,
`pain_points` | as in A |
| 5 | `brain_dump_skipped` | `prompt_set: Founder/CEO, prompts_seen: 0`
| Skip for now |
| 6 | `paywall_viewed` | `surface: onboarding` | plan step opens |
| 7 | `onboarding_step_viewed` | `step: subscription` | plan step opens
|
| 8 | `trial_offer_viewed` | `trial_offer_version: trial-v1,
subscription_tier: PRO, trial_duration_days: 7, surface: onboarding` |
plan step opens |
| 9 | `plan_viewed` | `plan_source: fallback, skipped_dump: true, tasks:
3, experts: 2, wait_ms: 103, gave_up: false` | plan step opens |
| 10 | `onboarding_step_viewed` | `step: preparing` | Start my free
trial → Stripe → back |
| 11 | `plan_experts_hired` (backend) | `hired: 2, already_on_team: 0,
failed: 0` | preparing |
| 12 | `intro_path` | `path: B` | Home |
| 13 | `home_suggestion_clicked` | `position: 0, surface: intro,
remaining: 4, owner: expert, expert_template_id: tpl-max, rank: 0` |
task 1 |
| 14 | `intro_followup_sent` | `chars: 63` | task 1 |
| 15 | `expert_thread_created` | `expert_id: exp-max, thread_source:
suggested_task` | task 1 |
| 16 | `home_viewed` | — | back to Home |
| 17 | `home_suggestion_dismissed` | `position: 1, surface: home,
remaining: 3, scope: one` | x on task 2 (Undo sends nothing) |
| 18 | `home_suggestions_collapsed` | `open: 4` | Hide |
| 19 | `home_suggestions_expanded` | `open: 4` | Show |
</details>

None of the removed events appeared. Sent by the backend, so not in a
browser walk here: `signup_completed`, `checkout_started`
(`checkout_kind: trial, surface: onboarding`), `trial_started`,
`trial_ending`, `trial_canceled`, `trial_converted`,
`payment_succeeded`, `expert_hired` (3 in A, 2 in B, `surface:
onboarding, via: plan`). Not reached by the walk's actions, covered by
unit tests: `plan_selected` (paid path,
`PlanTrialStep.paywall.test.tsx`), `checkout_abandoned` (cancel return),
`welcome_dialog_closed` (the walkthrough was marked seen).
`capability_card_*` were retired before this branch (SECRT-3149) and
stay reserved.

**Removed with the hire step:** `expert_recommended`,
`expert_recommendation_clicked`, `hire_all_clicked`,
`hire_step_continued` (in `RETIRED_EVENT_NAMES`, rows in the tracking
plan's Removed table). `hire_started` loses its `onboarding` surface
(the plan's hires have no click); it stays for `expert_page`. **After
deploy:** four saved PostHog insights read the removed events (u2ZK2Isk,
QfcpXRmM, 2OSZpNcv and ugcxrNQU). SECRT-3257 has the steps to freeze
each one at the release and the tested queries for the plan-first
versions, to do once this is live on prod.

### Langfuse

Tracked in SECRT-3225, to do before this reaches prod. The greeting's
instructions come from the Langfuse prompt **Brain Dump Greeting**
(`BRAIN_DUMP_GREETING_PROMPT_NAME`) whenever it is reachable; the local
copy in `intro.py` is only the fallback. The local copy now asks for one
optional key per suggested task:

> `"why"` (optional): one short clause, at most 60 characters, second
person, naming the thing they said this task fixes ("Because listings
eat your evenings."). Leave the key out when nothing they said ties to
the task.

The same line needs adding to the Langfuse copy, or every task in prod
arrives without a why line (nothing breaks; the line is just absent).
The parser keeps a `why` of 1 to 60 characters and drops a longer one
rather than cutting it. Nothing else in the greeting prompt changes.

### Settled decisions

- The paywall is replaced entirely by the plan + trial step;
`useSubscriptionStep`, `useTrialCard`, `tracking.ts`, the trial date
math (moved, tests kept) and the Stripe return handling stay.
- Tasks are ranked and assigned by TypeSafe Jev with a deterministic
fallback. Jev is a hosted model call: the request carries the role, pain
points, tasks, team and up to 6,000 characters of the transcript, with a
5.5 s budget.
- Experts are hired on the preparing screen after the trial is
confirmed; a user who abandons Stripe hires nobody (with no paywall,
when the user continues past the plan).
- The list is the design, the #16236 cards are removed; x removes a
task, Hide all became a collapse.

### Where this differs from the handoff, and why

- **`plan_source` and `thread_source`, not `source`.**
`docs/engineering/tracking-plan.md` reserves `source` for the emitter:
every browser event carries `source: web` as a super property, and an
event property of the same name replaces it. The repo settled this clash
before with `team_source` and `workflow_source`, so `plan_viewed` sends
`plan_source` and `expert_thread_created` sends `thread_source`.
- **A started task keeps naming who has it.** Edge case 11 (a fired
expert's tasks go to Otto) holds for open tasks. A task already started
links to a chat with that expert, so its row keeps their name; the
server reads it from the chat. Without this, firing Maria relabelled her
chat as Otto's, and hiring an owner after Otto took a task relabelled
Otto's chat.
- **Contrast.** The spec's "muted" grey (`#83838C`) is under 4.5:1 for
small text, so the why line, the role, the sent rows' title and the
toggle use `foreground-secondary`, and "In progress with {Name}" uses
`success-foreground` rather than `success`.
- **Undo focus.** Focus moves to the neighbouring row on remove. After
Undo, the toast (sonner) returns focus to where it was before the toast,
which is that neighbour, rather than to the restored row; fighting the
toast's own focus return would make focus jump twice.
- **The brain dump step** gained one read-only accessor
(`whenSkipSettled`), so the plan read waits for a Skip still in flight
(`7fb3dce4`). Its behaviour is unchanged.
- **Module split.** The backend plan lives in seven small modules
instead of one `plan.py`, because backend AGENTS.md caps files at about
300 lines.
- **Jev request budget.** Owner candidates are the recommendation's
first 6, each task's prompt is cut to 600 characters / 1,200 bytes, and
the transcript to 6,000 characters / 12,000 bytes, then 6,000 and 3,000
bytes if the state still does not fit. A character-only cut sent every
long Japanese or Chinese dump to the fallback.
- **The plan API's `source` is `llm`** only when the greeting wrote the
stored tasks. A completed dump whose greeting fell back to the generic
starters gets the role set (owned from the dump's team) and reads as
`fallback` with no "heard" line.
- **Pending plan.** While the pipeline runs, `GET /plan` answers `ready:
false` with the role plan, unstored, and `progress` (tasks written, team
chosen). A voice take still transcribing counts as pending. At 30 s the
step calls `POST /plan/settle`, which stores that role plan as the
take's tasks and team unless a plan is already stored; see "The plan
step's wait".
- **Reads that store** are built from reads that raise, so a failed
template or wizard read fails the request (the step keeps polling)
instead of storing a degraded list for good.
- **Ids.** Ranked rows keep their original position as `id`, so a task
keeps the id Home may already have handed out. Each owned task stores
`expert_name`, so the Otto toast can name an expert who has left.
- **Hire while pending (no paywall only).** A hire that arrives before
the pipeline finishes settles the plan first, so the team hired is the
role team whose tasks Home then lists; with the paywall the Stripe round
trip makes this window practically zero.
- **Archived experts.** A repeat plan hire can bring back an expert the
user archived, but only on an onboarding retake whose new plan names
them again.
- **Merged with dev's design-system lint (#16402, #16429).** The new
rules fail any violation in a file the baseline doesn't list, so this
branch's new components now use the primitives' own looks (`ef8ccdb6`).
Visible differences from the screenshots: avatars keep the design
system's own shape (rounded-xl) instead of a full circle, as dev's
onboarding does; the row's remove button is the Button's `toggle`
variant, quiet grey with no hover fill; the sending spinner is
`LoadingSpinner` (16px); the two 48px CTAs set their label at 15px
(`textSize="body-md"`) instead of 16px; the step card's corner is 24px
instead of 28px; the Start here pill is 11px with `tracking-eyebrow`.
The baseline only goes down (the task list 13 → 1, the block 9 → 3, the
deleted card's entry gone). Dev's role-draft test (#16398) now steps
from the brain dump straight to the plan step.
- **Commit trailer.** AGENTS.md asks for a `Co-authored-by` trailer
naming the model and the platform; the commits use the session's trailer
with the platform added.

### The plan step's wait

Found testing the preview: the step waited for the whole brain-dump
pipeline. That was the Sonnet greeting with adaptive thinking on, the
team and integration picks, the memory extraction, the Jev ranking, then
the dump's `completed` status. Past the old 20 s give-up, the step
showed the role plan while the hire and Home used the pipeline's, so the
team named on the step could differ from the one hired. Fixed in this
PR:

- **Less to wait for.** The greeting call answers without thinking first
(`intro.py`, reasoning off like the pipeline's other calls, `max_tokens`
4,000). The team job asks for the six experts the plan reads
(`plan.MAX_OWNER_CANDIDATES`), not sixteen. Only the greeting, team and
plan jobs end the step's wait. The provider picks and the memory
extraction run beside them and write their own columns; a failed
understanding write now costs the context, not the take.
- **Progress, not skeletons.** The pending answer carries `progress`.
The step shows it as a checklist where the heard card will be: "Turning
your brain dump into tasks", "Finding experts who fit your work",
"Choosing who takes each task". It polls every second (was 2.5 s) and
announces "Your plan is ready" from a live region outside the busy
skeleton.
- **A plan the step gives up on is the plan.** The step gives up at 30 s
(was 20 s). It then calls `POST /plan/settle`, which stores the role
plan it showed as the take's tasks (rows with ids and states) and team.
The greeting still writes its text, but leaves tasks already stored
alone (`db.store_greeting`, conditional on an empty list). The team job
leaves a stored team alone (`db.replace_team`, a conditional write on
`updatedAt`, because a reset take holds a JSON null and an older row a
SQL null). The plan job finds nothing to rank. So the trial hires, and
Home lists, what the step showed. The cut-off moved to 30 s because
giving up now costs the plan the brain dump would have written.
- **Back and re-record.** The remembered plan (for a Stripe cancel
return and the preparing screen's label) is forgotten whenever a new
take or skip is sent.
- **Measuring it.** The preview sends nothing to PostHog, so the new
wait isn't measured yet. The backend now logs each leg ("Brain dump
greeting for user … took Ns", "Brain dump team for user … took Ns",
"Brain dump plan ready for user … Ns after the pipeline started").
`plan_viewed`'s `wait_ms` and `gave_up` give the same in PostHog after
release. `gave_up` now means the 30 s wait ran out, and `plan_source`
says whose plan was shown.
- **Tests.** BE: `service_test.py` (the plan is ready before the
provider picks and before the understanding is stored, a failed
understanding write costs the context, tasks and team stored while the
jobs ran stand, a lost team write is retried), `routes_test.py` (pending
progress, settling mid-pipeline, settling a ready plan changes nothing,
settling a take still being transcribed reads as recorded, hiring while
the jobs run settles first, and the end-to-end "a plan the step gave up
on is what the trial hires and Home lists"),
`suggested_tasks_db_test.py` (the conditional writes against Postgres),
`intro_test.py` (reasoning off). FE: `usePlan.test.tsx` (1 s polls,
settles at 30 s, falls back when the settle fails, waits for a skip in
flight, keeps a plan that lands during the settle), `PlanPanel.test.tsx`
(the checklist and the announcement).

### Not run here, and notes

- **Stripe test mode and a real `posthog.debug()` walk.** This
environment has no backend stack or Stripe account. The flow was walked
in `next dev` with every call mocked (Stripe Checkout intercepted to the
success return), with PostHog captured in the page. The Playwright smoke
and journeys suites ran in CI and passed with the updated helper.
- **CI: two `experts_db_test` failures from skills-catalog `main`.**
Catalog PR #24 (30 Oct 15:50 UTC) changed day-one rows and roles, which
broke `test_wave_three_experts_are_skills_only_with_ordered_packs` and
`test_roster_day_one_promises_match_work_the_expert_can_do_on_request`
on every branch
([details](https://github.com/Significant-Gravitas/AutoGPT/pull/16308#issuecomment-6527907044)).
Dev fixed them in #16443, merged here in `8d81826a`.
- **Out of scope, seen in passing.** `home_viewed` does not fire on the
first Home visit, because `HomeRecap` mounts only once the greeting has
retired; the same on dev. The app sidebar logs a hydration mismatch on
some loads (its mobile/desktop branch differs between server and
client); the dev captures show it too.
- **`POST /api/experts` stays open while paywalled.** Its entry in
`paywall_routes_test.py` dates from when onboarding hired the team
before the plan picker. Onboarding no longer calls it; whether it should
now check the paywall is a separate decision, so I only updated the
comment.
- **The greeting fell back on the preview.** In the product manager's
test, Home showed the generic greeting ("Thanks for talking me through
your work — I've got it…"). That means the greeting call failed twice,
and the plan then took the Product/PM starter set instead of tasks
written from the brain dump. The owner fix above makes that set the
team's work, but the tasks still come from the role, not from what the
user said. The cause is not visible from here: no model keys in this
environment. The preview backend logs it as `Brain dump greeting
generation failed (attempt N): <ErrorType>: <message>`, now with the
type.
- **`Other` and the team of three.** Every plan now starts with three
experts (`d6ea5524`). For Other, or no role, the user's pain points
lead, then the experts the Other starter set needs (Quinn, Maya, Nadia),
so its research, post, summary and watch tasks have owners; its first
task, finding the work to hand off, stays Otto's. On a thin roster, any
seat still empty is filled from general skills and never with a role
that names no skill, so a filler may own no task.

### Edge cases

Each of the handoff's 16 cases, with the tests that cover it. Paths:
**BE** `backend/api/features/onboarding_dump/`, **OB**
`frontend/src/app/(no-navbar)/onboarding/`, **PP**
`OB/steps/SubscriptionStep/components/PlanPanel/__tests__/`, **ST**
`frontend/src/app/(platform)/copilot/components/SuggestedTasks/__tests__/`.

| # | Case | Covered by |
| --- | --- | --- |
| 1 | Skipped brain dump | BE
`routes_test.py::test_a_skipped_dump_gets_the_role_plan_stored_on_first_read`
(fallback, `skipped_dump`, Founder team, Home gets the same titles and
ids); PP `PlanPanel.test.tsx` "speaks to the role for a skipped dump" |
| 2 | Dump still generating, 30 s give-up settles the plan, no swap | PP
`usePlan.test.tsx` "settles the plan at 30 s and shows the plan the
server settled on", "keeps the settled plan when a poll in flight at 30
s lands later", "keeps a plan that lands while the settle is on its
way"; BE `test_the_plan_is_pending_while_the_pipeline_runs` (a pending
read stores nothing),
`test_a_plan_the_step_gave_up_on_is_what_the_trial_hires_and_home_lists`.
A *failed* dump answers at once with the role plan
(`test_a_failed_take_plans_and_hires_from_the_role_not_the_team_it_stored`).
|
| 3 | Jev unset, timeout, error, cut state | BE `plan_test.py` (no key,
timeout or raise, slow call, failed/empty/cut call, oversized state all
return None; a timeout or raise logs one warning),
`plan_job_test.py::test_without_jev_the_greeting_order_is_kept_with_hinted_owners`.
An unset key warns once at import, like the copilot gate. |
| 4 | Zero experts | BE
`plan_owners_test.py::test_no_recommended_experts_leaves_every_task_with_otto`,
`routes_test.py::test_a_plan_with_nobody_on_its_team_hires_nobody`; PP
"leaves the tasks with Otto when nobody joins"; OB
`usePreparingStep.test.ts` "says it is setting up the team when the plan
has nobody to hire" |
| 5 | Fewer / more than three | PP "shows fewer cards when the plan has
fewer tasks"; ST `suggested-tasks.test.tsx` "keeps the rows after the
first three under a closed '2 more tasks' row that opens on click" |
| 6 | Owner outside the capped team | BE
`plan_owners_test.py::test_a_team_of_four_owners_keeps_three_and_nulls_the_fourth`,
`plan_job_test.py::test_a_jev_owner_left_off_the_capped_team_goes_to_otto`
|
| 7 | Back and re-record | PP `usePlan.test.tsx` "does not show a plan
cached before the mount while the refetch is pending", "reads the plan
only once a skip still on its way has landed"; BE
`service_test.py::test_a_new_take_never_serves_the_previous_takes_answers`
|
| 8 | Stripe cancel return | PP "shows the remembered plan at once on a
checkout return", "does not report a restored plan as a second view"; OB
`PlanTrialStep.paywall.test.tsx` "reports checkout_abandoned when Stripe
sends the user back without paying"; `usePreparingStep.test.ts` "never
hires before the trial is confirmed"; BE
`routes_test.py::test_a_user_still_behind_the_paywall_hires_nobody` |
| 9 | No trial offer | OB `PlanTrialStep.paywall.test.tsx` "offers one
Pro checkout beside the plan…"; PP "names the moment the team joins"
("joins your team when you subscribe") |
| 10 | Hire fails for one or all | BE
`test_a_failed_hire_is_reported_and_the_rest_still_join`; OB "never
blocks Home on a failed hire, and reports it", "moves on after the
ceiling when the hire never answers"; ST "shows each row's owner… and
Otto… for an expert not on the team" |
| 11 | Expert fired between visits | BE
`suggested_tasks_test.py::test_each_owned_task_names_its_hired_expert`,
`test_a_started_task_stays_with_the_expert_its_chat_is_with_once_fired`;
ST "gives Otto a task whose expert has left the team, says so, and shows
it in progress with Otto", "keeps naming the expert a started task went
to after that expert is fired". The toast fires when such a task is
started, once (the row then becomes a link). |
| 12 | Old progress with `hire` | OB `progress.test.ts` /
`page.test.tsx` "resumes a draft saved on the retired hire step at the
plan step"; `store.test.ts` "never lays out the retired hire step" |
| 13 | Old rows without owner or why | BE
`suggested_tasks_db_test.py::test_prompts_stored_before_owners_existed_still_rehydrate`,
`intro_test.py::test_a_prompt_keeps_its_why_and_one_without_still_parses`;
ST "shows a task's why under its title, and nothing there when it has
none" |
| 14 | Role `Other` | BE
`plan_owners_test.py::test_other_and_unknown_roles_take_the_other_set`;
PP "keeps the line role-free for Other". See the note on `Other` above
for the team. |
| 15 | Motion, keyboard, viewports, phones, collapsed list | PP "offers
nothing to click or focus"; ST `SuggestedTasksBlock.test.tsx` "still
hides and shows the list when the user prefers less motion", "keeps the
list folded for the same user after a reload…", `helpers.test.ts` "never
collapses the list for a signed-out visitor…". By reading the code and
in the screenshots: tab order on the step is Back, Start my free trial,
Log out (DOM order; the progress bar has nothing focusable); 1280 × 720
and 1366 × 768 fit without scrolling and phones keep the sticky trial
button (screenshots); rows stack under `sm` with the x always visible on
touch; the collapse is a per-user key that sign-out does not clear. |
| 16 | Expert limit mid-hire | BE
`test_the_expert_limit_stops_the_hire_and_reports_the_rest`,
`test_the_expert_limit_on_the_second_hire_keeps_the_first_and_reports_the_rest`;
Home's Otto fallback is case 10's path |

Screen reader and keyboard: rows announce "Start with Maria: {title}"
and, once sent, "Open the chat with Maria: {title}" (ST); the toggle
reports expanded and collapsed (ST); the walkthrough's Start here step
lands on the row with the pill (`first-run-tour.test.tsx` "starts on a
task's own button, never its Remove button"); focus after Remove goes to
the next row, its Remove while locked, or the composer when the list
goes (ST "keyboard and screen readers", `SuggestedTasksBlock.test.tsx`
"focus after Remove").

### Ticket lines

Resolves SECRT-3201
Resolves SECRT-3132
Resolves SECRT-3131
Progresses SECRT-3134 (Start here and ranking shipped; account
requirements per task still open)
Progresses SECRT-3142 (owner per task shipped; Otto proposing delegation
in chat still open)
Related SECRT-3211 (what happens to a task after its chat finishes), out
of scope
Related SECRT-3212 (integrations on suggested tasks), out of scope
Related SECRT-3225 (the Langfuse why line, before release) and
SECRT-3257 (the PostHog insights, after release)

### Agents and large language models used

- Claude Code (model reported in the commit trailers)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan (all but a
Stripe test-mode run, below):
- [x] Backend: `poetry run pytest backend/api/features/onboarding_dump
backend/api/features/experts backend/api/paywall_routes_test.py` plus
dev's `onboarding_role` and `onboarding_wizard` tests: 1,172 passed, 29
skipped after the dev merge. `export-api-schema` matches the committed
`openapi.json`. isort, black and pyright clean on every changed file.
- [x] Frontend: `pnpm lint` and `pnpm types` exit 0 on the final head
(the only warnings left in files this PR touches predate it); `pnpm
test:unit`: 12,834 passed + 4 expected failures after the dev merge,
including the design-system baseline tests. React Compiler coverage and
bailout tests pass with no new bailouts.
- [x] Each commit checked on its own in a clean worktree (types and the
suites it touches).
- [x] Screenshots of every changed screen, desktop and phone, plus 1280
× 720, 1366 × 768 and 320 × 640 for the plan step (linked above). Mocked
`next dev`; light theme only.
- [x] Both onboarding paths walked to Home with every PostHog
`capture()` recorded, twice each (lists above).
- [x] Two review passes with independent verification of each finding;
the confirmed ones are fixed in `3782dfc4` (a started task's owner), the
give-up fix in `130b92ad`, and `6eda537b` (focus after Remove).
- [x] Playwright smoke and journeys suites: passed in CI with the
updated `completeOnboardingWizard`.
- [ ] Stripe test-mode run: not possible in this environment (no backend
stack, no Stripe).

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
(two new optional model settings, commented out; the Jev key is the
existing `TYPESAFE_API_KEY`)
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): none

No data migration is required: the new fields are optional, and rows
written before this change read as Otto-owned with no `why`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01G58r45FBHQQQwJGu1SgbxC

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Krzysztof Czerwinski <[email protected]>
2026-11-06 10:39:04 +00:00
9cb76f9026 docs(platform): Changelog v0.11.0 (#16651)
Resolves SECRT-3392. **Merge after #16315** (v0.10.0): this PR is
stacked on its branch, and GitHub retargets it to `dev` once #16315
merges.

### Why / What / How

**Why:** v0.11.0 shipped on November 4. The login page's What's new
panel (#16326) and the in-app What's new card both read the changelog,
so they show v0.10.0 as the latest until this lands.

**What:** a standalone entry for v0.11.0,
`docs/platform/changelog/november-4-2026.md`, listed in
`docs/platform/SUMMARY.md` and at the top of the table in
`docs/platform/changelog/README.md`. The What's new card shows the first
three highlights: "Connect tools from the chat", "The login page shows
what's new", "A friendlier first email".

**How:**
- Frontend changes people can see, written from each merged PR's
description. 28 links, all merged PRs in v0.11.0.
- Security work is never included in the changelog (a standing rule), so
none of this release's security PRs are described or linked.
- Flags checked in PostHog (project "AutoGPT Platform (PROD)") on
2026-11-05. Left out: background subtasks (#15733), behind
`copilot-background-subtasks`, which is employees only. The running
`trial-checkout-no-charge-copy` experiment is unchanged and nothing here
depends on it. Spend approvals (#16352, #16303) are in because
`expert-spend-approval` was retired as on for everyone.
- Also left out: the single Microsoft connection (#16268), since what
runs on it today is the Microsoft 365 Copilot chat connection, which we
keep out of the changelog with the other bring-your-own subscriptions;
the GitHub star prompt for self-hosted installs (#16527); the payment
and proxy settings for self-hosters (#16459, #16522); the LaunchDarkly
and Langfuse removals (#15853, #16026); the design-system and Tailwind
work that changes no pixels; bot-only changes, admin, analytics and test
work.
- Copy follows the user-facing rule in `AGENTS.md` (#15502). No em
dashes.

**Heroes:**
- `v0110-connect-hero.gif`: filmed on production in a signed-in session.
`@hubspot` typed in the Home chat box (never sent), Connect an
integration, then the Connect a service dialog and its Popular section.
The Home recap behind the first frame is blurred because it holds
internal notes.
- `v0110-login-hero.gif`: filmed headlessly on the live login page, then
a click through to signup.
- `v0110-confirm-email-hero.png`: the after-render from #16279, cropped
above the "Button not working?" box so no verification link appears.

### Changes 🏗️

- `docs/platform/changelog/november-4-2026.md`: the new entry
- `docs/platform/.gitbook/assets/`: the three heroes above
- `docs/platform/SUMMARY.md`: the entry under What's New
- `docs/platform/changelog/README.md`: the row at the top of the table

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
"test_*.py"` passes
- [x] Every PR link in the entry is a merged PR in v0.11.0, and each is
in the release tag
- [x] No security items, no em dashes, no "agent", "graph", "AutoPilot"
or "Copilot" in the copy
- [x] Flags and experiments checked against production PostHog on
2026-11-05

#### For configuration changes:

No configuration changes.

---
_Generated by [Claude
Code](https://claude.ai/code/session_018TYcprig4Ui3qGu5smRtmN)_

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Bentlybro <[email protected]>
2026-11-05 21:18:08 +00:00
Toran Bruce RichardsandClaude Opus 5.5 30a2ba1221 feat(blocks): add Treg blocks for people, company and web data (#16393)
### Why / What / How

**Why:** [Treg](https://treg.to) is "OpenRouter for agent tools": one
token reaches 4,000+ API endpoints across ~120 providers (Apollo,
Hunter, People Data Labs, DataForSEO, Exa, scrapers and more), billed
per call at the provider's price from a prepaid balance, with no
provider signup. Its headline use is people search and lead enrichment
([launch post](https://x.com/treg_ai/status/2114674262179188755)).
AutoGPT only lists Treg as an MCP server today, so workflows can't use
it as typed steps. Closes OPEN-3790.

**What:** A `treg` provider (API key, `TREG_API_KEY`) and 21 blocks:

- **People and contacts:** Search People, Find Work Email, Find Personal
Email, Find Phone Number, Verify Email, Enrich Person, Find LinkedIn
From Email
- **Companies:** Search Companies, Enrich Company, Find Similar
Companies, Find Company Jobs
- **LinkedIn:** Get LinkedIn Profile, Get LinkedIn Company
- **Web:** Web Search (web, news, Google, Google News, research papers,
YouTube), Google Maps Search, Extract Web Page, Extract Structured Data,
Map Website
- **Catalog:** Search Catalog and Get Tool (free: find a tool, its
inputs, price and every alternative provider), and Call Tool (call any
catalog tool, or one your Treg team registered)

**How:** The typed blocks call Treg's *routed* tools
(`treg.<capability>`), which try providers cheapest first (your team's
own keys first) and fall through on a miss. A shared `TregBlockBase`
sends each call through the SSRF-safe `Requests` wrapper and records the
charge Treg reports.

- **Misses are not errors.** Every routed block emits `found` (false
when nobody had an answer), plus `served_by`, `providers_tried`,
`cost_usd`, `call_id`, Treg's `advice` and the provider's `raw` answer.
Typed fields are emitted only when present and of the declared type, so
a provider's `"500+"` never fails a run.
- **Spending is capped.** Every paid block has `max_cost_usd` (default
$0.50, max $25; Call Tool defaults to $1, Treg's own per-call default),
sent as `X-Treg-Route-Max-Cost`, and `try_more_providers` to stop after
the first provider. Calls are never retried, because a repeated paid
call can bill again. A run that names nobody (e.g. a name without a
domain) fails before any call.
- **Found is not confirmed.** Find Work Email and Find Personal Email
check the mailbox in the same call by default (`X-Treg-Route-Verify`),
and `verified` is true only when that check says `valid`; a catch-all
comes back as `catch_all`.
- **Billing:** `X-Treg-Cost-Micro` (or `X-Treg-Reserved-Micro` for a
call still pending) is recorded as `provider_cost` before any error is
raised, Treg blocks run on the user's own Treg key only, which the
platform doesn't bill. The platform's shared `treg-default` key is
refused, because `treg` isn't in `RUNNABLE_SDK_DEFAULT_PROVIDERS` (the 1
Nov rule for vendors that may keep per-account state). The `COST_USD`
base cost (150 credits/USD) would only apply if Treg is added to that
list after checking it's safe.
- **Call Tool safety:** tool references that could leave `/call/` (`..`,
encoded dots or slashes, query or fragment) are refused before sending.
Caller `X-Treg-*` headers other than the route options are refused,
case-insensitively, so the spending cap can't be overridden. On the
platform's key only catalog ids are accepted, since that key's Treg team
is shared by every user. Redirects are off so the token can't be
forwarded.
- **Errors** carry Treg's code and message (e.g. `insufficient_balance`
with the top-up link, or a hint to check the key on a 401).

### Changes 🏗️

- New `backend/blocks/treg/` package: provider config, HTTP client,
shared base and schemas, 21 blocks in 9 modules, unit tests
- `.env.default`: adds blank `TREG_API_KEY=`
- `mcp_catalog.json`: the existing `mcp_treg` entry gets `"provider":
"treg"`, so a Treg MCP connection files under the same service as the
new API-key credential (the field exists for exactly this, as on
`mcp_apollo`)
- Generated block docs under
`docs/integrations/block-integrations/treg/` with the manual sections
written; README and SUMMARY updated by the generator
- No frontend change: `frontend/public/integrations/treg.png` already
exists

**Screenshots.** After: searching "Treg" in the builder lists the new
blocks (captured by the review bot's sandbox on `6ec1ad2`):

![Treg blocks in the builder block
search](https://pub-0d18968687014244a588926709ee135f.r2.dev/agent-browser/b6ca263df1ce4c9aa1db0aebc9d0ef55/proof.png)

Before: no before screenshot. On `dev` the builder has no Treg blocks;
Treg is only an MCP server in the MCP catalog. This PR changes no
frontend code, and the blocks render from their schemas like every other
SDK block.

Found and left out of scope:
- Polling an asynchronous generation (video, image) to completion. Call
Tool returns the task descriptor as `async_task`.
- Not yet run with a real Treg key (one is being set up); see the test
plan.

### Agents and large language models used

Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `test_available_blocks` and the other per-block registry tests
pass for all 21 blocks (mocked answers)
- [x] `backend/blocks/treg/test_treg_*.py` (86 tests): request path,
body and spending headers per block; misses as `found: false` and still
billed; charges recorded before a failed call raises; pending calls
billed at the reservation; nothing sent when no subject is given; email
`verified` only on a `valid` check; each Web Search source routed
correctly; Call Tool method, async task, refused bad tools, refused Treg
headers in any case, platform key limited to catalog ids, failure inside
a 2xx; LinkedIn references sent as URLs only for LinkedIn hosts;
off-type and non-string values left out; error messages for each
error-body shape; tool-reference validation; type coercion
- [x] Client asserted: `X-Treg-Token` only, one attempt, no redirects,
timeout, query flattening, reserved headers dropped
- [x] Live against treg.to: Search Catalog and Get Tool return real
tools, prices, success rates and 40 alternative providers; a routed call
and Call Tool with a fake key return `HTTP 401 ... invalid token`
- [x] `generate_block_docs.py --check` in sync; black, isort, ruff and
pyright clean on the package
- [x] `backend/copilot/capabilities` tests pass (Otto's capability
search still finds the experts for "someone to run my LinkedIn")
  - [ ] Run with a real Treg key

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-05 20:47:38 +00:00
John AbabsehandClaude 0865d245d2 docs(platform): Changelog v0.10.0 (#16315)
Resolves SECRT-3216. Rebuilt on `dev` after #16020 (v0.9.0) merged.
**Merge before the v0.11.0 changelog PR**, which is stacked on this one.

### Why / What / How

**Why:** v0.10.0 shipped on October 27. Once #16020 merges, the public
changelog and the in-app What's new card stop at v0.9.0.

**What:** a standalone entry for v0.10.0,
`docs/platform/changelog/october-27-2026.md`, listed in
`docs/platform/SUMMARY.md` and at the top of the table in
`docs/platform/changelog/README.md`. The What's new card shows the first
three highlights: "A brain dump that helps you answer", "A calmer trial
welcome", "A guided first run".

**How:**
- Leads with the brain dump step (#16252), then the new trial welcome
(#16036, kept short since it changes again soon), then the frontend
improvements from Abhi's PRs: the first-run walkthrough (#16107), hire
and start in one click (#16101), the usage and credits explainer
(#16095) and the Airtable base picker (#16094). 39 links, all merged PRs
in v0.10.0.
- Security work is never included in the changelog (a standing rule), so
none of this release's security PRs are described or linked.
- Flags checked in PostHog (project "AutoGPT Platform (PROD)") on
2026-10-27. Nothing listed depends on a flag that is off for the public.
Left out: the "no charge today" line on trial checkout (#16050, #16143),
a running 50/50 experiment (`trial-checkout-no-charge-copy`); the
approval preview for Airtable changes in #16094, which only shows with
`copilot-auto-mode` (employees only).
- Also left out: the Google Map in chat (#15939), which only renders
when a Maps key is set in production and that isn't confirmed yet;
picking an Expert or mode from the Discord and Telegram bots (#15863,
not in the web app, and the smart mode is plan-restricted); External API
v2 (#12206, developer-facing); design-system and React Compiler
refactors that change no pixels; admin, analytics, test and self-hosting
work.
- Copy follows the user-facing rule in `AGENTS.md` (#15502). No em
dashes.

**Heroes** (all six in place):
- From production, filmed in a signed-in session: the first-run
walkthrough (`v0100-first-run-hero.gif`, Show me around, with the Home
recap behind the dim blurred because it holds internal notes) and the
usage explainer (`v0100-usage-explainer-hero.gif`, the wallet, then How
it works).
- From the screenshots in the PRs, because production only shows these
screens to a new account or mid-task: the brain dump
(`v0100-brain-dump-hero.gif`, from the renders in #16252), the trial
welcome (`v0100-trial-welcome-hero.png`, #16036), the hire card before
and after Hire and start (`v0100-hire-handoff-hero.png`, #16101) and the
Airtable base picker (`v0100-airtable-picker-hero.png`, #16094).

### Changes 🏗️

- `docs/platform/changelog/october-27-2026.md`: the new entry
- `docs/platform/.gitbook/assets/`: the six heroes above
- `docs/platform/SUMMARY.md`: the entry under What's New
- `docs/platform/changelog/README.md`: the row at the top of the table

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
"test_*.py"` passes
  - [x] Every PR link in the entry is a merged PR in v0.10.0
- [x] No security items, no em dashes, no "agent", "graph", "AutoPilot"
or "Copilot" in the copy
- [x] Flags and experiments checked against production PostHog on
2026-10-27
  - [x] All six heroes in place

#### For configuration changes:

No configuration changes.

https://claude.ai/code/session_018TYcprig4Ui3qGu5smRtmN

---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <[email protected]>
2026-11-05 20:10:24 +00:00
Toran Bruce RichardsandClaude Opus 5.5 70d0b65e5e feat(blocks): bring the Exa integration up to date with Exa's current API (#16395)
### Why / What / How

**Why:** Our Exa integration had fallen behind Exa's API, and part of it
was broken.
- All four Exa Research blocks fail on every run. Exa retired
`/research/v1` on 2026-04-01, and it now answers `410 RESEARCH_RETIRED`.
- Exa's newer products (the Agent API and Search Monitors) had no blocks
at all.
- The Search, Contents and Answer blocks offered search types and
categories Exa has since replaced, and lacked its current options.
- The Websets webhook trigger has never worked. It registered webhooks
at a URL that returns 404, sent Exa the wrong event names, and read the
wrong payload fields.

Linear: OPEN-3789.

**What:** Every current Exa endpoint relevant to workflows now has a
block. Existing blocks expose Exa's current options. Saved workflows
keep working: no block ID, input name, output name or enum value was
removed or renamed.

**How:** The new endpoints go through one small HTTP client
(`_exa_rest.py`) instead of `exa-py`. The installed SDK rejects values
Exa now returns (`pending`, `canceled`, `failed`), drops response fields
we need (`output`, `warnings`, `entities`), and checks options against
an allow-list that lags behind the API. Errors carry Exa's own message
and tag.

### Changes 🏗️

**Broken blocks fixed**
- The four Research blocks run again, on Exa Agent (`/agent/runs`), with
the same IDs, inputs and outputs.
  - Model maps to effort: fast→low, standard→medium, pro→high.
  - Statuses map back to the old vocabulary.
- `cost_pages` and `cost_reasoning_tokens` are still emitted, as 0,
because Exa Agent doesn't report them.
- The Websets trigger now works and is enabled:
  - correct URL (`/websets/v0/webhooks`)
  - Exa's own event names
  - Exa's payload fields
  - the `webset_id` filter is actually applied
  - five new `monitor.*` events, off by default

**New blocks (30)**
- **Exa Agent (8):** run (waits by default), get, wait, list, cancel,
stop, delete, list events.
- Covers all seven efforts with their prices, output schemas with
field-level citations, input and exclusion records, follow-up runs, Exa
Connect data sources, and cost and duration budgets.
- **Search Monitors (10):** create, get, list, update, delete, batch,
trigger now, list runs, get run.
- Plus a **Search Monitor Trigger** that starts a workflow on each
delivery and checks Exa's signature once the monitor's secret is pasted
in.
- **Websets (12):**
  - webhook create/get/update/delete/list
  - webhook delivery attempts
  - event list/get
  - Websets monitor runs list/get
  - import update
  - team concurrency info

**Existing blocks brought up to date**
- **Search:**
- Types: `auto`, `lite`, `deep-lite`, `deep`. The legacy `fast`,
`instant`, `deep-reasoning`, `neural` and `keyword` are kept, labelled
with their replacements.
- Categories: `people` and `publication` added. `research paper` is sent
as `publication` and `linkedin profile` as `people`, per Exa's
changelog.
- New options: structured output (`output_schema` → `output_content` /
`output_grounding`), `system_prompt`, `additional_queries`, `objective`,
`user_identifier`.
  - Outputs Exa's `warnings` and `notices`.
- Checks lite's parameter allow-list before calling, instead of
surfacing a bare 400.
  - Crawl-date filters are no longer sent; Exa ignores them.
- **Contents settings** (Search, Find Similar, Contents):
- New: `max_age_hours`, `snapshot_as_of`, text verbosity and section
filters, highlights `max_characters`, `rich_image_links`.
- Legacy `livecrawl` and `num_sentences` are translated with Exa's
documented mappings. Deprecated params are no longer sent by default.
- **Answer:** `model` (exa / exa-pro / exa-fast / exa-research),
structured answers (`answer_structured`), `system_prompt`,
`user_location`, `user_identifier`, and cost output and billing.
- **Find Similar:** marked deprecated by Exa (pointing to Search), with
`exclude_source_domain` and `category` added.
- **Websets audit**, for example:
  - the preview request body was wrong;
- canceled searches waited until timeout because the code checked
`"cancelled"`;
  - imports were sent under the wrong key;
- added title, recall, maxPeoplePerCompany, exclude sources, the `url`
enrichment format, and the list/item filters.
- **Labels:** Search Type and Category now show readable labels instead
of `ExaSearchTypes` and a lowercase `category`, as do the new blocks'
enum and object fields.

**Billing and safety**
- **Blocks that keep or spend something in the Exa account need the
user's own Exa key.** Everyone on the platform's Exa key shares one Exa
account, so Websets, their searches, items, enrichments, monitors and
imports, Exa Agent and Research runs, Search Monitors, and the Websets
webhook and event blocks refuse the platform's shared key ("Use Credits
for Exa search" and `exa-default`) with a message telling the user to
add their own key. Batch Search Monitors could otherwise delete every
user's monitors, and a Websets webhook would forward everyone's events.
Search, Answer, Contents, Find Similar, Code Context and a plain Preview
Webset still run on the platform key and are billed from Exa's reported
cost.
- **Exa Agent and Research runs therefore only happen on the user's own
key.** Exa bills those runs to the user's Exa account and the platform
charges no credits for them (the Exa base cost only applies to the
platform's keys). The blocks still report the run's Exa cost once per
user per run: several blocks can see the same finished run (Exa Agent,
Get, Wait, Cancel, Stop and the Research blocks), and a Redis `SET NX`
claim keeps that reported figure from being counted twice. If Redis
can't be reached within 5s, the figure is recorded anyway.
- **Preview Webset with "include sample items"** runs a real Websets
search, and Exa's preview response carries no cost to bill, so that
option needs the user's own key. A plain preview stays allowed on the
platform key.
- **Up-front refusal.** Blocks that refuse the platform key declare it
(`refuses_platform_credentials`), so graph validation rejects a platform
credential on them before the graph runs or is scheduled, with the same
message used for refused SDK defaults, and the builder no longer
auto-selects the platform credential for them. The check inside `run()`
stays as the backstop.
- **Long waits fit the executor cap.** Exa Agent / Wait for Exa Agent
Run (up to 3 h), Create Research / Wait For Research (1 h) and the
Websets wait blocks (30 min) set `execution_timeout_seconds` to their
input's ceiling plus 5 minutes, so their own timeout output fires
instead of the executor cancelling `run()`. Run-starting blocks yield
the run ID before waiting.
- Exa REST calls send writes and deletes once (no automatic retry of
POST/PATCH/DELETE), retry reads a bounded number of times, and drop the
`x-api-key` header on a cross-origin redirect.
- IDs are URL-escaped in every path.
- The Agent API's nested `{"error": {...}}` responses are shown as
readable messages.

**Follow-up fixes in this PR (A/B/C)**
- **A. Preview Webset sample items** ran a real search on the shared key
with nothing billed. That option now refuses the platform key; a plain
preview doesn't.
- **B. AutoPilot couldn't run any Exa block for users without their own
key** (pre-existing on `dev`). The credential matcher counted the SDK
`exa-default` credential as the user's own, so a watched chat picked it
and the credentials manager then refused it, and a user with their own
key was asked to choose between it and `exa-default`. An SDK default
that isn't runnable for its provider (not in
`RUNNABLE_SDK_DEFAULT_PROVIDERS`) now never matches a credential field,
so the "Use Credits" system credential is used. Runnable SDK defaults
(e.g. Tavily) are unchanged.
- **C. Nothing told the system which blocks refuse the shared key**, so
graphs with them saved, scheduled and ran and only failed at the node.
The new `refuses_platform_credentials` block marker is read by graph
validation and exposed on the block's credentials field schema so the
builder doesn't auto-select a system credential for such a block. Exa's
stateful blocks and SmartLead's campaign blocks set it; a test keeps the
marker and the run-time refusal in agreement.
- Removed the Exa-only `_platform_key` guard: every block using it
already refused the shared key in `run()` first, so it never fired.

**Docs:** `docs/integrations/block-integrations/exa/` was regenerated,
with hand-written "how it works" sections for every new block and
updated sections for the changed ones.

**Behaviour changes worth knowing**
- Search now forwards all of its content settings. Before, it silently
dropped livecrawl, subpages, extras and context.
- Invalid inputs now fail early with a clear message instead of an Exa
400. Examples: a non-ISO country code, lite with unsupported options,
more than 5 webset criteria, `refresh` Websets monitors (removed by
Exa).
- `Webset.status` is a plain string now, so new Exa statuses don't crash
runs.
- Saved Highlights settings with only a query now get Exa's adaptive
length instead of 1 sentence. Saved graphs that stored the full default
object behave the same.

**Found and left out of scope**
- Exa's webhook signatures carry a timestamp that is not checked for
replay. Exa documents the check as optional and doesn't say whether
retried deliveries are re-signed, so a fixed window could drop
legitimate retries. This predates the PR; the new trigger uses the same
check.
- Create Research is still declared a read (`effect=READ`) although it
starts a paid run, as it was on `dev`; Exa Agent now declares
`EXTERNAL_JUDGED`.
- Most Websets item/enrichment/import-create/export calls still use
`exa-py`, whose models match the spec there.

### Screenshots (local stack: `dev` at 5bea25d vs this branch, same
browser and account)

**Block menu, searching "Exa"**: new blocks appear (Exa Agent Run, Get
Agent Run, …), and the Research blocks say they run on Exa Agent.

| Before | After |
|---|---|
| ![Before: Exa block
menu](https://github.com/user-attachments/assets/9a9d685b-f7a6-4614-a498-fa7c5279cf9b)
| ![After: Exa block
menu](https://github.com/user-attachments/assets/e1fdf0b0-4599-4c11-9d27-90abbaeb1a1f)
|

**Exa Search, Search Type**: Exa's current types, with legacy values
kept for saved workflows. The label was "ExaSearchTypes".

| Before | After |
|---|---|
| ![Before: search type options keyword, neural, fast,
auto](https://github.com/user-attachments/assets/0f252bcc-25bb-44a0-a93d-7eccb9a770df)
| ![After: search type options auto, lite, deep-lite, deep plus
legacy](https://github.com/user-attachments/assets/81978307-0c55-4ec9-99a2-c529ef3c14c5)
|

**Exa Search, Category**

| Before | After |
|---|---|
| ![Before: category
options](https://github.com/user-attachments/assets/f2b5f986-29fe-46fa-ad68-0adbfc18b48d)
| ![After: category options with people and
publication](https://github.com/user-attachments/assets/27dc21bd-86d1-47b2-aa9b-9dfb3dfc2ea3)
|

**Exa Answer**: model, structured output, system prompt, location.

| Before | After |
|---|---|
| ![Before: Exa Answer
node](https://github.com/user-attachments/assets/2bd72811-5e84-4609-b06e-bae91425cc19)
| ![After: Exa Answer
node](https://github.com/user-attachments/assets/52f109f0-be55-4132-8494-c30ddb3d6179)
|

**Exa Create Research**: same inputs, now backed by Exa Agent.

| Before | After |
|---|---|
| ![Before: Exa Create Research
node](https://github.com/user-attachments/assets/4f9a8e04-8fe0-467d-a19a-1d6c6717d730)
| ![After: Exa Create Research
node](https://github.com/user-attachments/assets/94d5260b-ffd6-4dfc-9448-814c4fdd5084)
|

**New blocks: Exa Agent Run and Exa Create Search Monitor**

| Exa Agent Run | Exa Create Search Monitor |
|---|---|
| ![New Exa Agent Run
node](https://github.com/user-attachments/assets/d90f34b0-52cb-40e0-b049-473147c2c7eb)
| ![New Exa Create Search Monitor
node](https://github.com/user-attachments/assets/88793a20-a78c-480f-9b07-731526517a1a)
|

Full Exa Search node:
[before](https://github.com/user-attachments/assets/52288ed2-72ac-4b04-9fba-64dbda77bef6)
·
[after](https://github.com/user-attachments/assets/3f612de4-9db0-4465-9932-28e5312cc5db)

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] `pytest backend/blocks/exa
backend/blocks/block_cost_tracking_test.py`: 345 passed
- [x] `pytest backend/blocks/test/test_block.py -k Exa`: 474 passed
(every Exa block's test input, output and mock)
  - [x] `pytest backend/util/architecture_test.py`: 16 passed
- [x] black, isort, ruff and pyright clean on changed files;
`generate_block_docs.py --check` in sync
- [x] Saved-graph compatibility: imported every pre-existing Exa block
from `dev` and this branch and compared the input/output schemas
recursively. No ID, field or enum value was removed.
  - [x] Builder renders every changed and new block (screenshots above)
- [ ] Live calls against Exa with a real key. No key was available while
building, so payloads were checked against Exa's published OpenAPI spec
(`exa.ai/docs/exa-spec.yaml`) and mocked responses. **Worth one run each
of Exa Agent, Search (deep + output schema), Answer (model), Create
Search Monitor and the Webset trigger on a preview with a real key
before merging.**

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**). None.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 16:30:49 +00:00
Toran Bruce RichardsandClaude Opus 5.5 78749c15b4 feat(blocks/firecrawl): add Firecrawl Alexandria data tool blocks (#16561)
### Why / What / How

**Why:** Firecrawl launched Alexandria, pitched as "headless Clay for
agents": search 250M+ people and 26M+ companies, get work emails, job
and company data, plus thousands of other structured-data providers
(jobs, finance and filings, public records, places, retail), all on the
same Firecrawl API key. Our Firecrawl blocks only cover scrape, search,
crawl, map and extract, so neither graphs nor AutoPilot can reach it.

**What:** Two new Firecrawl blocks, following Firecrawl's own
discover-then-run flow (the same one its MCP server and CLI use):

- **Firecrawl Find Data Tools**: free catalogue search. Describe the
data you need ("sales leaders at Series B software companies") or name
providers, capabilities or categories; get back matching tools with
their provider and capability IDs, price in Firecrawl credits and input
contract.
- **Firecrawl Run Data Tool**: runs one tool (provider + capability +
options) and returns its records and the credits it cost.

**How:** Both call `POST https://api.firecrawl.dev/v2/scrape` with an
`alexandria` body
([docs](https://docs.firecrawl.dev/features/alexandria)); discovery is
the built-in `firecrawl/find-tools` capability. Plain HTTP through
`Requests` because the locked `firecrawl-py` (4.42) predates Alexandria;
this avoids a dependency bump. Each run sends one `x-request-id` across
retries so Firecrawl runs a retried paid call once.

Billing reuses the Firecrawl provider's existing `COST_USD` rate:
`provider_cost` is the `creditsCost` Firecrawl reports × $0.001, the
same credit value the other Firecrawl blocks use. Cost is recorded
before a failed tool raises, since a failed call can still be charged. A
`THIRD_PARTY_DATA_TERMS_REQUIRED` refusal names the Firecrawl dashboard
link where an org admin accepts that provider's terms.

### Changes 🏗️

- `backend/blocks/firecrawl/alexandria.py`: the two blocks and the
shared Alexandria call.
- `backend/blocks/firecrawl/alexandria_test.py`: request shape, timeout
cap, terms and other refusals, billing (including on a failed tool),
catalogue option building.
- Generated block docs, with the manual sections filled in.

Not changed: the existing Firecrawl blocks, and the Firecrawl Agent
`exchange` option (we have no Firecrawl Agent block).

### Agents and large language models used

Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] `test_block.py` passes for both blocks (mocked run, effect, IDs)
  - [x] `alexandria_test.py` passes (9 tests)
- [x] ruff, isort, black, pyright clean on the new files;
`generate_block_docs.py --check` in sync
- [x] AutoPilot's capability index returns both blocks for "find sales
leaders at Series B software companies", "find work email for a person",
"enrich company from domain" and "people search by job title"
- [ ] Live call against Firecrawl with a real key (not run: no
Alexandria-enabled key available locally; the keyless API refuses
provider tools)

**Before this works on the platform key:** the team behind our
`FIRECRAWL_API_KEY` needs Alexandria access, and an org admin must
accept the terms of any provider that requires them (Firecrawl dashboard
→ Settings → Data sources).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:32:46 +00:00
Nicholas TindleandClaude Mythos 5.1 ea4a36ff27 fix(platform): generate the Web Push (VAPID) key pair per install (#16416)
### Why / What / How

**Why.** `autogpt_platform/backend/.env.default` has shipped a working
Web Push (VAPID) private/public key pair since #12723, and
`frontend/.env.default` the same public key. The file is public, so
every self-host that kept the defaults signs push notifications with a
key anyone can read: whoever holds a user's push endpoint can notify
that user as the site. It is the same class as the retired JWT and
Fernet defaults, lower impact, and it slipped past the
`check-env-defaults` guard because the pair was never on its list.

**What.** The pair is blank in both `.env.default` files and generated
per install by `make init-env`, the single-container first boot
regenerates a published pair, and the backend refuses to sign with the
published key while still starting.

**How.** `fill-env` fills `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` as
one, from a single generated P-256 key, and treats the published pair as
blank (like a service password: nothing stored depends on it, so
replacing it costs only the browsers' subscriptions). A pair with one
side set is refused rather than half-filled, since the public key is
derived from the private one, and so is a pair whose public key is not
the private key's, in `make init-env` as on the container's boot. A
32-byte private key outside the curve's scalar range is refused as well
(no public key derives from it), while one that is not a raw scalar is
left to the backend, which also reads a DER key. A pair with the
published key on one side and another key on the other is refused on the
container's first boot rather than half-replaced (the dropped half would
fail the next boot's changed-value check), and `make init-env` keeps a
pair that holds a `$NAME` reference, which Compose fills in when the
stack starts. A `backend/.env` from before the pair existed gets it
appended, the way a later-declared service password is. `make init-env`
runs under the system interpreter, which has no `cryptography`, so
generation falls back to interpreter-only P-256 arithmetic; that path is
checked against the RFC 6979 A.2.5 vector and against `cryptography`
where it is installed. The backend's secrets guard warns on the
published private key (by SHA-256 digest of the canonical Base64url
spelling of the bytes it decodes to, since py-vapid reads a padded,
wrapped or standard-alphabet key as the same key a decoder ignores the
last character's unused bits, and a key of another length is read as
DER, so such a key is reduced to its scalar first; the value is not
reproduced) and `push_sender` skips sending with it, saying why once per
process. The frontend takes `NEXT_PUBLIC_VAPID_PUBLIC_KEY` over the key
it fetches from `GET /api/push/vapid-key`, so `fill-env` clears a
published value from `frontend/.env` and otherwise leaves it alone. A
single container started with the published pair in its environment
replaces it on first boot and accepts that same environment on every
later boot, and one that persisted the published pair under an earlier
release replaces it on its next boot the same way.

### Changes 🏗️

- `backend/.env.default`, `frontend/.env.default`: VAPID pair blank;
comments say how it is generated.
- `single-container/runtime_config.py`: `VAPID_KEY_SETTINGS`,
`PUBLISHED_VAPID_DIGESTS`, pair-wise fill in `fill_env_blanks`,
missing-line backfill of the pair, clearing of a published
`NEXT_PUBLIC_VAPID_PUBLIC_KEY`, published-pair regeneration in
`_configured_or_generated_vapid` with `_verify_environment_matches`
passing over it, replacement of a persisted published pair on the
existing-config path, canonical-spelling digest, interpreter-only P-256
fallback, backend pair added to `BLANK_IN_ENV_DEFAULT`.
- `backend/util/secrets_guard.py`: `vapid_private_key_is_published`
(canonical spelling, a DER key reduced to its scalar), warning on the
published key, hint that says to recreate the services.
- `backend/data/push_sender.py`: refuses to send with the published key,
warning once per process.
- `Makefile`, `docs/platform/getting-started.md`: upgrade note.
- Tests: fill, keep, refuse half-set, refuse a pair that is not one, a
scalar outside the curve order and a half-published pair, keep a pair
with a Compose reference, a DER encoding of the published key, replace
published, append to an older file, clear the frontend's published copy,
padded and wrapped spellings, environment regeneration, two boots on the
published environment, a persisted published pair replaced, RFC vector,
agreement with `cryptography`, no-`cryptography` path (blocked on every
import name, scalar draw patched so a `cryptography` pair fails it);
guard warning and spellings; push sender refusal warned once; guard and
generator digests cross-checked.

Configuration changes: `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` are
now blank in `.env.default` and generated by `make init-env`;
`NEXT_PUBLIC_VAPID_PUBLIC_KEY` is blank in the frontend's and cleared
from an existing `frontend/.env` when it holds the published key. An
install that still has the published pair sends no pushes until `make
init-env` replaces it and the services are recreated; browsers
subscribed under the old pair re-subscribe by turning notifications off
and on.

### Agents and large language models used

Claude Code with Claude Mythos 5.1.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `single-container/tests/test_runtime_config.py`: all 80 pass on
Linux (WSL, Python 3.10, `cryptography` installed, so the
no-`cryptography` test's blocking is exercised for real); on this
Windows machine the remaining failures are the pre-existing
`os.fchmod`/file-mode ones that also fail on untouched `dev`
- [x] `check-env-defaults --root autogpt_platform` passes on the real
files
- [x] `backend/util/secrets_guard_test.py` +
`backend/data/push_sender_test.py`: 50 passed
  - [x] black, isort, ruff, pyright clean on the touched files

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Mythos 5.1 <[email protected]>
2026-11-04 14:06:23 +00:00
Reinier van der LeerandClaude Opus 4.8 4c8601afc3 feat(platform): Support input nodes alongside trigger nodes (#11220)
This PR lets a triggered preset carry the agent's own inputs beside its
trigger config, so an agent can have both a webhook-trigger node and
input nodes instead of choosing one. The v2 External API presents the
two as separate fields.

- Resolves [OPEN-2547: Support input blocks alongside trigger
blocks](https://linear.app/autogpt/issue/OPEN-2547/support-input-blocks-alongside-trigger-blocks)

### Why

Previously a triggered preset stored *only* the trigger config as its
`inputs`, and the run modal fell back from the graph's input schema to
the trigger's config schema — so there was nowhere to put a
webhook-triggered agent's regular graph inputs, and the copilot's
building prompt told Otto never to add an input node beside a trigger.
That meant you couldn't configure regular graph inputs on a
webhook-triggered agent.

### What

- A triggered preset stores the agent's inputs and its trigger's
settings side by side. A webhook delivery runs the agent with those
inputs, and the trigger node gets its settings plus the event's payload.
- Setting up a trigger takes the agent's inputs: `constant_inputs` on
the internal `/presets/setup-trigger` route and the copilot
`setup_agent_webhook_trigger` tool, `inputs` on the v2 `POST
/library/presets/setup-trigger`.
- Editing a trigger edits either half alone: the copilot `update_preset`
tool and the v2 `PATCH /library/presets/{id}` take `trigger_config`
beside `inputs`.
- The v2 preset responses carry the agent's inputs in `inputs` and the
trigger's settings in a new `trigger_config`, with its secret settings
listed in `hidden_inputs` as before.
- Existing triggered presets are converted to the new shape at deploy
and on each REST boot.
- A preset attached to a webhook runs only when its trigger fires:
running it directly answers 400 on both execute routes.

### How

- A preset's `inputs` now hold the regular graph inputs **plus** the
trigger node's config nested under a per-node key
`_node_input_mask_{node_id}`. The name matches the executor's existing
`nodes_input_masks` concept.
- `TriggeredPresetSetupRequest` gains a `constant_inputs` field (regular
graph input values). `setup_triggered_preset` wraps the trigger config
under the per-node key and stores `constant_inputs` alongside it.
- On a webhook delivery, `_execute_webhook_preset_trigger` separates the
two again: regular inputs are passed as graph `inputs`, while the
trigger config + webhook payload become the trigger node's
`nodes_input_masks` entry. The event-type filter is checked against the
**unwrapped** trigger config.
- `update_triggered_preset` extracts the trigger config from the same
per-node key when re-registering the webhook. Delivery, the PATCH route
and the copilot `update_preset` tool split a preset's inputs through one
helper, `split_trigger_inputs`, which reads a preset with no mask as all
trigger config.
- The v2 routes translate between the stored shape and the two fields. A
reader without the preset's graph splits it with
`split_stored_trigger_inputs` (`library/model.py`), which takes the
object under the mask-prefixed key as the trigger config and reads an
attached preset with no mask as all trigger config. A PATCH rebuilds the
stored shape from `inputs` and `trigger_config`, each left out keeping
its saved value, and puts back the hidden secret settings a new
`trigger_config` leaves out. `trigger_config` on a preset with no
trigger is a 400. A new `trigger_config` on an active trigger needs
`RUN_AGENT`, as new `inputs` do, because it re-registers the webhook;
the MCP `update_preset` tool asks for it on the same terms.
- The setup/update/delete logic lives in shared helpers
(`api/features/library/triggers.py`) reused by the `/presets` routes,
the copilot preset tools, and a DatabaseManager RPC endpoint, so all
paths produce/consume the same input shape.
- A data migration (`migrate_preset_trigger_params`) converts the bulk
of existing triggered presets at deploy time. Its trigger-block list is
fixed when the migration is written and an applied migration cannot
re-run, so the authoritative conversion is
`migrate_flat_triggered_preset_inputs()`, which runs on each REST boot
and derives the block set from `get_webhook_block_ids()`. It also
reaches presets whose webhook was detached, telling them from a
run-template preset on the same trigger-bearing graph by comparing the
preset's input names against the trigger block's config schema. The
query carries the half of that test SQL can express — attached, or
holding an input some trigger block declares — so a run-template preset
is selected only when one of its inputs is named like a trigger field,
which costs it a graph load on every boot; otherwise the converged
database costs one selective query and no per-preset work; on the first
boot after the migration it is bounded by `asyncio.wait_for(...,
timeout=30)`, and since it fetches in id-ordered pages of 100 and each
preset commits on its own, a timeout defers the remainder to the next
boot rather than delaying the port. Every replica runs it, so a
conversion skips when its delete finds the flat rows already gone: the
first replica's delete holds them until commit, so a second that read
the same preset deletes none and rolls back instead of adding a second
mask row. Until then, a preset that is still flat is read with its
inputs as the trigger config, as before the mask existed: a delivery
runs with them, and an edit saves them nested under the mask.

### Changes 🏗️

- Add `constant_inputs` to `TriggeredPresetSetupRequest`; wrap the
trigger config under `_node_input_mask_{node_id}` and store regular
inputs alongside it
- Update webhook execution to split the node input mask from regular
graph inputs, and check the event filter against the unwrapped trigger
config (fixes event-filtered triggers such as GitHub raising "Event
filter is not configured on trigger")
- Add a Prisma data migration to convert existing triggered presets
- Tests for the wrap/extract helpers and the webhook execution path
- Derive the trigger-block set from the registry rather than a literal
list; wrap the preset version bump and mask re-key in one transaction;
validate updated inputs in `update_triggered_preset` the way setup does;
give the copilot `update_preset` tool a separate `trigger_config`, so
graph inputs stop landing inside the trigger config; and stop the UI
reporting success on a trigger edit it silently dropped
- Let the PATCH route, the copilot `update_preset` tool and the
library's trigger view edit an attached preset the boot backfill has not
converted yet, instead of refusing it for missing its mask; the edit
stores it nested. The trigger view reads such a preset as all trigger
config, as the backend does
- Log the backfill's designed timeout as a warning without a traceback
- Reconfigure the trigger only for a preset that is attached or holds
the mask, so editing a run-template preset on a graph that also has a
trigger node is a plain update
- Store the trigger config merged with the new credentials when a
trigger preset's credentials change, so the preset no longer keeps
executing with the credentials the user replaced (the stored mask
overrides `preset.credentials` at execution)
- Read a triggered preset's webhook secret (Generic Webhook's
`secret_token`, All Quiet's `signing_secret`) from inside the mask
through `preset_trigger_configs`, which returns every object under a
mask-prefixed key because a graph input may use the prefix too, so a
preset set up with a secret still refuses unsigned deliveries
- Validate a trigger preset's inputs at setup and reconfigure as
delivery does, except for credentials and the not-yet-received payload.
The check ran as a dry run, which also skips the schema check, so a
trigger config delivery refuses (a GitHub event filter `{"opened":
"yes"}`) registered its webhook and then failed every event.
`validate_and_construct_node_execution_input` gains a `trigger_setup`
flag for this. It leaves absent credentials and the trigger's
placeholder `payload` unchecked, so a typed payload such as Compass's
`TranscriptionDataModel` doesn't refuse setup; delivery still validates
the real payload. Each block keeps its own input check:
`get_mismatch_error` takes the same `exclude_fields` as `validate_data`,
so a block's override still runs in place of the base schema check
(MCP's and AgentExecutor's leave their dynamic inputs to their targets)
- Let a preset whose agent has no credentials update its inputs:
`db.update_preset` refused non-empty `inputs` beside an empty
`credentials` map, so editing a Generic Webhook trigger returned 400
(also broken on `dev`)
- Remove the two remaining places the old either/or rule was still
stated: the copilot's `agent_generation_guide.md` (so Otto builds the
combined shape and passes `constant_inputs` at trigger setup) and
`validate_io_blocks`'s error text
- Apply the split/merge helpers on the library page's Templates tab,
where a triggered preset whose webhook was detached is filed: its stored
trigger config renders again, and "New agent task" no longer seeds the
mask key into `constant_inputs`. The split is memoised, so a parent
re-render no longer resets an open run modal's form
- Trim 303 chars of restatement from both copilot tools' descriptions,
so the two new parameters (`constant_inputs`, `trigger_config`) cost 178
net rather than 481. The trim is a saving, not what keeps the PR under
- Raise both tool-schema ceilings for the two new parameters: the
registry's `_CHAR_BUDGET` 77,810 → 78,204 and the largest session's
`_SESSION_WIRE_BUDGET` 70,108 → 70,526
- Give the v2 preset routes `trigger_config` beside `inputs`, on the
response and on PATCH, and `inputs` on setup-trigger; update the
External API authentication guide to match
- Ask for `RUN_AGENT` when the MCP `update_preset` tool is given a new
`trigger_config`, as for new `inputs`
- Label `trigger_config`, not `constant_inputs`, "Trigger settings" in
`setup_agent_webhook_trigger`'s schema
- Find a preset's trigger config by its prefix and an object value in
the copilot `update_preset` tool and the library's trigger views, as the
backend does, so a graph input named with the prefix stays one; trigger
setup and reconfigure refuse a graph input that would itself read as a
trigger config (a prefixed name holding an object), so a triggered
preset holds exactly one
- Re-key only the trigger config row when a preset moves to a new
version, picked by the same rule, so a graph input named with the prefix
keeps its name
- Refuse graph inputs in the copilot `update_preset` tool on a trigger
the boot backfill has not nested yet, with the trigger view's message,
until its trigger settings are saved once
- Check what the preset writer stores before a webhook is registered,
which #16483 (a count) and #16485 (a 256 KiB byte bound, and the name
and description lengths) check against the flat config: the graph
inputs, the nested trigger config as one row, and the credentials. The
slot #16483 reserves for the webhook `payload` is the trigger node's,
not a preset row, and the executor validation this branch runs at setup
and reconfigure already counts it there
- Refuse a direct run of a preset attached to a webhook on the internal
and v2 `/presets/{id}/execute` routes, with a 400 saying it runs when
its trigger fires, as the copilot's `run_agent` already did. Its trigger
node gets a payload only from a delivery, so such a run could never
start, and it is now refused before the stored trigger config would be
passed on as the agent's inputs

> The two parameters add 178 characters to the registry and 164 to the
largest session (measured against `dev` at 7ca3671f6a). Each ceiling was
set to its measurement plus 300; on this branch merged with `dev` at
bbbe18bf89 the registry measures 77,895 and the largest session 70,210,
leaving 309 and 316 chars of headroom, so both ceilings hold unchanged.

### Verified

Executed here: the new `migrate_flat_triggered_preset_inputs()`
discriminator across all four preset cohorts, the backfill's
steady-state cost and its wrapping of a preset the SQL migration missed,
`update_triggered_preset`'s input validation and its mask/graph-input
split, the copilot tool's routing of `inputs` vs `trigger_config`, an
edit of a still-flat attached preset through `update_triggered_preset`
and through the copilot tool (driving the real
`update_triggered_preset`), both saving it nested, the refused save on a
mask-less preset, the read-only inputs panel, and the Templates-tab
memoisation (a `trigger-agents.test.tsx` case that types into the open
run modal and re-renders the parent). Each was run against a mutation of
the guard it covers and failed, so none passes vacuously; the new
`useSelectedTemplateView` tests fail 3 of 5 against the pre-fix hook.

The webhook secret: a Generic Webhook preset created with a
`secret_token` through the real `setup_triggered_preset` on Postgres
accepted unsigned and wrong-secret deliveries before the fix and refuses
both after; `test_stored_trigger_secret_still_guards_ingress` drives
that setup path for both providers and fails 2 of 2 with the readers
reverted; its two cases with a string graph input under the mask prefix,
stored ahead of the mask, fail with a first-prefixed-key lookup
restored. `test_a_graph_input_read_as_a_trigger_config_is_refused` and
`test_an_update_with_a_graph_input_read_as_a_trigger_config_is_refused`
fail with the setup or the reconfigure refusal removed.

The credential-less update: through the real `PATCH
/api/library/presets/{id}`, a Generic Webhook preset with no credentials
returned 400 before the fix (and on `dev`) and 200 with the new config
stored after, still on its one webhook;
`test_update_preset_accepts_an_empty_inputs_or_credentials_map` fails 2
of 2 with the old guard.

The setup and reconfigure validation:
`test_setup_refuses_what_delivery_refuses` and
`test_reconfigure_refuses_what_delivery_refuses` run the executor's own
validator on a GitHub pull-request trigger beside a number input. A
non-boolean event filter is refused, and a valid config with no
credentials passes. Reverting to `dry_run=True` fails both refusal
cases. Dropping the credential exemption, or either half of it in
`executor/utils.py`, fails all four.
`test_setup_accepts_every_trigger_block_before_its_first_event` sets up
each of the 14 enabled trigger blocks with its own example config, no
credentials and the placeholder. It refused Compass before the payload
exemption and fails again without it.
`test_setup_keeps_a_blocks_own_input_check` sets up an MCP tool beside
the trigger with no credentials; with the base schema check in place of
the block's own, it fails on `'server_url' is a required property`,
which delivery doesn't raise. A number input given `"abc"` (coerced to
`0`) and a required graph input left out pass this check, as they pass
delivery's.

The trigger view's flat presets: `useSelectedTriggerView.test.tsx` edits
a preset with no mask key as trigger config and refuses a graph input on
one; both fail against the previous hook.

The webhook paywall from dev:
`test_a_triggered_preset_runs_only_for_an_owner_with_a_plan` drives the
real ingress and preset path. A paywalled owner's delivery runs nothing,
and an allowed one runs with the graph inputs and trigger config apart.
Removing the paywall return fails it, and so does reverting the split.

The v2 presets: `presets_test.py` drives the real
`setup_triggered_preset` through `POST /presets/setup-trigger` and finds
the agent's inputs validated and stored beside the trigger config and
read back apart. A read, a listing and a PATCH of what was read return
no secret setting, and a PATCH of `inputs` or of a `trigger_config`
without the secret saves the secret unchanged. A legacy flat preset's
new config is stored under its graph's trigger node. Five mutations each
fail at least one test: returning the secret in `trigger_config`,
dropping the kept secrets, dropping setup-trigger's `inputs`, leaving
`trigger_config` out of the `RUN_AGENT` check, and losing the saved
inputs on a `trigger_config`-only PATCH. The MCP guard's
`trigger_config` case fails with the guard reverted.

The merge with `dev`: `migrate_webhook_presets_to_new_version` keeps
`dev`'s organization scope inside this branch's transaction, and
`test_presets_move_and_rekey_in_one_transaction_within_the_organizations`
fails on each of three mutations: the scope dropped, the version bump
outside the transaction, the re-key outside it. An update that clears
every input of a triggered preset is refused before any write, and one
that clears its graph inputs keeps the trigger config. Since #16472 the
backfill runs as an entry in the REST API's `_run_data_migrations`,
which keeps it bounded and off the boot's critical path;
`test_a_bounded_data_migration_that_times_out_is_a_warning` pins its
timeout as a warning rather than an alert and fails when that branch is
removed. Since #16483 and #16485 a triggered preset's inputs are counted
and their bytes measured before its webhook is registered, and each
check takes the set this branch's writer stores rather than the flat
one: one row per graph input, one for the whole nested trigger config,
one per credential. A delivery adds `payload` to the trigger node's mask
and not to the preset, so that node's count — including the placeholder
— is the one the executor validation here already makes, and no preset
row is reserved for it.
`test_preset_inputs_filled_to_the_bound_are_stored` and
`test_an_update_filled_to_the_bound_is_stored` pin the boundary the
writer allows on each path, while
`test_a_trigger_config_filled_to_the_bound_registers_no_webhook` and
`test_a_trigger_config_at_the_bound_carrying_payload_is_accepted` drive
the real validator for the trigger node's own count. Six mutations each
fail at least one test: dropping either preflight, counting #16483's
flat set at setup, reserving a `payload` row on either path, and
dropping the executor's node-input count.
`test_graph_inputs_over_the_byte_bound_register_no_webhook` covers the
graph inputs #16485's own tests leave unsized, and fails when setup
measures the flat set. `update_preset`'s own test of an empty `inputs`
or `credentials` map needed its stubbed transaction to carry #16483's
row lock and stored-row count, and still fails 2 of 2 with the old guard
restored.

The direct-run refusal:
`test_a_webhook_triggered_preset_cannot_be_run_directly` (v2) and
`test_execute_a_webhook_triggered_preset_returns_400` (internal) each
fail with the refusal removed from their route.

Suites executed: the backend suites this PR touches (integrations,
webhooks, library, triggers, `executor/utils_test.py`, spend-approval,
copilot presets and `manage_presets_test.py`,
`setup_agent_webhook_trigger_test.py`, the agent-generator validator,
`run_agent_test.py` minus the three tests that hang on `dev` locally,
`tool_schema_test.py`), `test_block.py`, `architecture_test.py`,
`orgs/regression_test.py`, `manager_test.py`'s two preset runs, the
whole `api/external/v2` directory (440 passed on the merged tree) and
`pyright` over the changed backend files; frontend vitest across
`library/agents/[id]` and `copilot/tools/SetupTrigger/`, and a clean
`tsc --noEmit`. The full migration directory was applied to a throwaway
`pgvector/pgvector:pg16`, and `migrate diff` against `schema.prisma`
showed only the drift `dev` already has; this migration contains no DDL.
The backfill also ran against a real Postgres: a detached (`webhookId =
NULL`) preset with flat `repo`/`events` rows on a graph carrying a
GitHub Issues trigger node — the cohort the SQL migration skips — was
rewritten to one `_node_input_mask_{prefix}` row holding the same
values, and a second run left it untouched. Two backfills run
concurrently against Postgres on the same flat preset leave exactly one
mask row; with the skip removed they leave two.

Not executed: the two manual end-to-end items below, and the backfill at
production scale — the timing of a large first-boot cohort is bounded by
the timeout rather than measured.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] Unit: `setup_triggered_preset` wraps regular inputs + trigger
config under `_node_input_mask_{node_id}`
- [x] Unit: `update_triggered_preset` extracts trigger config from the
per-node key; rejects reconfigure when a nested preset's key is missing;
converts a flat preset on edit
- [x] Unit: webhook execution passes regular inputs as graph `inputs`,
trigger config (+payload) as the node mask, and checks the event filter
against the unwrapped config
- [ ] Manual: build a graph with both a webhook-trigger node and input
node(s), set up a trigger with constant inputs, fire the webhook, and
confirm both the trigger payload and the regular inputs reach the
execution
- [ ] Manual: edit the preset's inputs and confirm the webhook
re-registers correctly

#### For configuration changes:

- [ ] `.env.default` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
- Adds Prisma data migration
`20251016151116_migrate_preset_trigger_params` (converts existing
triggered presets; no schema change)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-11-04 11:12:46 +00:00
Toran Bruce RichardsandClaude Opus 5.5 6574f3a118 feat(backend): warn canceled trials 3 days and 1 day before access ends (#16506)
### Why / What / How

**Why:** A trialist who cancels gets the cancellation confirmation, then
nothing until "Your AutoGPT trial has ended", when access is already
gone. Stripe does fire `customer.subscription.trial_will_end` for
canceled trials (17 of the last 40 live events), but the ending reminder
dropped them as "cancellation is scheduled". Closes
[SECRT-3324](https://linear.app/autogpt/issue/SECRT-3324).

**What:** Two new trial notices for trials set not to renew, each with
**Resume my trial** as its one action:

| Kind | When | Subject |
|---|---|---|
| `expiring` | 3 days before the end (Stripe's `trial_will_end`) | Your
AutoGPT trial ends in 3 days |
| `last_call` | 18 to 25 hours before the end (new hourly pass) | Your
AutoGPT access ends tomorrow |

**How:** Both are ordinary `TRIAL_UPDATE` kinds, so they reuse the trial
notice pipeline: the ownership and checkout-attempt checks, a dedupe
claim per trial end date, the freshness check at delivery, and the
billing sender, kit and footer. When `trial_will_end` arrives for a
cancel-pending trial, it now becomes `expiring` instead of being
dropped. The scheduler runs `send_trial_last_calls` at :15 every hour,
in the database manager like the Stripe tier sweep. The pass lists
running, unconverted, cancel-pending trials whose end falls in the
window, and calls `notify_trial(..., "last_call")` for each. If one
trial fails, the pass logs it and carries on.

These are service messages: like the other trial notices, they go out
even to people who opted out of product news. So they stick to facts
about the person's own account, with no "Last chance" framing or urgency
styling. Persuasion belongs to the MailerLite win-back sequence.

### Journey (7-day trial, canceled on day 2)

Day 2: cancellation confirmed → day 4: `expiring` → day 6: `last_call` →
day 7: trial ended.

- `ending` ("you'll be charged") and `expiring`/`last_call` are mutually
exclusive on `cancel_at_period_end`, so nobody gets both kinds for one
trial end.
- Each new notice goes at most once per trial end. Cancel → resume →
cancel again does not resend it.
- Neither goes to someone who canceled in the last day (Stripe's
`canceled_at`): they have just had the cancellation notice, with the
same date and the same way back.
- A resume, a conversion or another change before delivery suppresses a
queued notice, as for the other trial notices.
- No MailerLite group change: the trial group already dropped them when
they canceled.

### Before / after

**Before:** a canceled trialist got no email at either point. The 3-day
`trial_will_end` event was logged as `Dropped trial ending notice for
user …: cancellation is scheduled`, and there was no 1-day email at all.

**After** (rendered from this branch's templates with fixture data, Pro
at the live trial price of $50.00 / month):

| 3 days before | Last day |
|---|---|
| <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/expiring-phone.png"
width="300" alt="Your AutoGPT trial ends in 3 days"> | <img
src="https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/last_call-phone.png"
width="300" alt="Your AutoGPT access ends tomorrow"> |

Desktop: [3 days
before](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/expiring-desktop.png)
· [last
day](https://raw.githubusercontent.com/Significant-Gravitas/AutoGPT/2c7016e13dd73d56199e35138b1453044404fd4b/docs/engineering/email-review/secrt-3324/last_call-desktop.png)

Layout: a short lede, then the plan panel (plan, access end, auto-renew
off), then two option cards, "If you resume" (with the price) and "If
you do nothing", then the button with the cancel-again and tax note. All
text meets WCAG AA contrast: labels and notes #575072 on #F5F3FC (6.8:1)
and on white (7.5:1), body #2D2A3E on #F5F3FC (12.6:1), button text
white on #6144DF (6.2:1). The plain-text part carries the same content
in the same order.

### Found and left alone

- **MailerLite overlap:** the live "Canceled trials resurrection"
automation sends "Trial cancelled, but you can still use it" on trial
day 5 or 6 to canceled trialists in the marketing audience. Once this
ships, it lands between the 3-day and last-day emails. This PR doesn't
change it; that's a separate decision.
- In the plain-text version of the other trial emails, the CTA link runs
into the "You received this…" line. Only the two new kinds are laid out
cleanly here.

### Changes 🏗️

- `notifications/trial.py`: the `expiring` and `last_call` kinds, their
windows, the recent-cancel rule, and `send_trial_last_calls`.
- `data/subscription_trial.py` + `db_manager.py`:
`list_trials_ending_unrenewed`, and `send_trial_last_calls` exposed over
RPC.
- `executor/scheduler.py`: the hourly `send_trial_last_calls` job.
- Trial templates (HTML, text, subject): the layout above.
- PostHog `trial_expiring` and `trial_last_call` events, sent on enqueue
like the other trial events and pinned in `posthog_events_test`. The
tracking plan and the email design doc are updated.
- Tests cover rendering (including plain-text layout and no urgency
styling), which reminder applies, the windows, the recent-cancel skip,
claim keys, converted trials, the pass's error isolation, and job
registration. `trial_audience_cancel_test`'s "no ending reminder while
pending" test now expects the `expiring` notice, since this ticket
changes that behaviour on purpose.

### Agents and large language models used

Claude Code with Claude Opus 5.5.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Trial, design-system, approval-copy, freshness and PostHog event
tests pass locally (258), plus the scheduler registration test
- [x] Rendered both emails at phone and desktop width and checked
contrast
  - [ ] CI green
- [ ] After deploy: a canceled trial with under 4 days left gets the
3-day email on its next `trial_will_end`, and the last-day email on the
hourly pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 11:08:19 +00:00
Nicholas TindleandClaude Fable 5.1 f5f05f638c feat(platform): make background subtasks real work that returns to the chat (#16582)
### Why / What / How

[SECRT-2813](https://linear.app/autogpt/issue/SECRT-2813), second
increment. #15733 woke Otto after a background subtask for a bounded,
tool-free summary: the child had ten minutes, the follow-up two,
delivery a ten-minute window, and the result was pasted into the wake
prompt. Nick's call on review: time is not a budget, and the wake should
return to the main thread rather than deliver a summary. This makes
background work real.

**What changes for the user:** a background subtask runs until it is
done, and when it finishes, fails or is stopped, Otto carries on in the
original chat with everything it had, as soon as that chat is idle,
however long that takes. No deadline, no delivery window, no expiry
notice.

**How:**

- The child is spawned like a foreground subtask: the launching turn's
tools minus the descent denials, its budget tree, cost attribution,
credential pins, route and scope, and it may hand work on within the
tree's depth. It keeps no deadline of its own; only a deadline the
launching turn already had is enforced (`background_timeout` returns
`None` otherwise).
- The wake runs as a continuation of the launching turn under that
turn's own envelope (same tools, spawn rights and spend ledger), as an
unattended turn. It is governed by the user's allowance and spend limits
the way every turn of that chat is, not by the subtask's node, so a
spent subtask tree no longer blocks the chat from carrying on.
- The wake message names the subtask and its outcome and points at
`get_sub_session_result`. What the subtask produced stays in its own
chat and reaches the model as a tool result, the way a foreground
subtask's does, instead of as text inside a user message. The `result`
column and the row-selection query go away; the UI keeps the short
labeled notice.
- Delivery waits for an idle parent indefinitely; the "follow-up window
expired" notice is gone. Recovery still records a delivered continuation
from Redis and records an interrupted child as failed when its chat went
idle without an outcome. A child (or continuation) whose worker died is
released by the sweep with the orphan rule the chat routes already
apply: still running its claimed turn, no live stream, untouched for
thirty seconds. That is a liveness check, not a work deadline, and it is
now shared as `ORPHAN_RUNNING_RESET_SECONDS`.
- A cancelled turn, a chat's or a child's, cancels the background work
it launched and suppresses those wakes, so stopping a chat reaches
grandchildren now that a child may delegate.
- When the child's turn ends, the record notes which row holds its
answer (`resultSequence`, nullable, on this PR's migration, backfilled
for any task completed before it): every row after the launch is the
turn's, a clarification it drained mid-turn included, when the worker
still holds the turn or when the chat records no later turn
(`_active_turn_id`); only once a later turn ran does recovery stop at
the next user row. `get_sub_session_result` asks the record for every
sub (one indexed read) and, for a background child, returns that turn's
answer and tool calls, so a later turn in the child's chat is never
handed back as the subtask's result, with the child's workspace files
listed as for a foreground sub but only those written in the turn's
window, from its launch to the next user message after its answer (a
browser screenshot is saved for the child by another tool, so the turn's
tool calls alone would miss it, while a later turn's files stay out);
until the outcome is recorded it reports the child as still running
rather than reading a partial transcript as a result.
- A child still queued past a deadline it inherited is closed as timed
out by the sweep (pickup would refuse it anyway); a child with no
inherited deadline waits as long as it takes.
- A launching turn whose allow-list leaves out `get_sub_session_result`
is refused background work at launch (its continuation could not read
the result), and the child session starts in the approval mode the
parent's gate enforces, as the foreground path already does.
- Cancelled or refused queued children give their node back; the
continuation never used it.
- `GET /sessions/{id}` reports `background_pending: bool` instead of
`background_pending_until`; the tool output drops
`background_expires_at`; the card polls a background subtask to its
outcome rather than giving up at the foreground cap.
- Migration: `deadline` becomes nullable and `result` is dropped
(`20261104020000_background_subtasks_no_clock`). The flag is off and no
rows exist, so the mixed-worker window carries no tasks.

### Changes 🏗️

- `background_task.py`, `background_subtasks.py`,
`background_execution.py`, `background_delivery.py`,
`background_task_db.py`, `turn_queue.py`, `db_manager.py`, `routes.py`,
`run_sub_session.py`, `models.py`, `schema.prisma` + migration: the
points above. Net about 530 lines smaller.
- Frontend: `SubSessionLive`, `AgentCards`, `ChainRowView`,
`backgroundSubtasks.ts`, the session mock helper and the regenerated
`openapi.json`.
- `docs/engineering/background-subtask-delivery.md` rewritten for the
new behaviour; the harness investigation section is unchanged.

### Agents and large language models used

Claude Fable 5.1 (Claude Code, in T3 Code).

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description.
- [x] I have made and executed a test plan:
- [x] Review round (Sentry, fourth): a child whose record waited for its
own handoffs ends the way its last continuation ended, so a failed
continuation reports a failed subtask rather than a completed one (the
nested-handoff test runs both endings).
- [x] Review round (CodeRabbit, fifth pass): the result tool reports a
promotion refusal (a spent tree, say) from the child's chat ahead of the
failed record the sweep writes
(`test_result_tool_reports_why_a_background_child_was_refused`); a
completed child without an answer row still bounds its file listing by
its turn's window, and a record without a window lists nothing rather
than everything; the window is applied in the workspace query itself,
ahead of the 50-file manifest limit (`created_after`/`created_before` on
`list_workspace_files` and `WorkspaceManager.list_files`, with a
real-database test); and the sweep's orphan release leaves a chat alone
while a worker still refreshes its session lock, whatever became of the
stream metadata (`locked` case of the dead-worker test).
- [x] Review round (Sentry, third): a child that hands work off itself
no longer completes, and wakes the original chat, when its own turn ends
with that work still out; its record waits for the last of that work to
come back to it (`has_open_handoffs`), the sweep leaves such a child
alone meanwhile, and the record completes with the child's latest answer
when its last continuation ends
(`test_a_child_that_handed_work_off_wakes_the_parent_once_that_work_is_back`).
- [x] Review bot's second-round optional advice folded in: a
real-database test for the sweep releasing a continuation whose worker
died (`test_sweep_recovers_a_continuation_whose_worker_died`), and
`_reconcile` split into settling the child's outcome and acting on it.
- [x] Review round (CodeRabbit, fourth pass): the answer query reads the
chat's active turn in the same statement as the rows (one snapshot, no
window for a turn admitted in between); a recorded background result
lists the child's workspace files from the authoritative listing,
restricted to its turn's window so a later turn's stay out
(`test_result_tool_lists_a_background_childs_workspace_files`,
`test_the_sub_manifest_can_be_restricted_to_a_window`); the sweep
re-arms the ledger key of every tree with open background work so its
spend and node counts survive the wait, reopening being the fallback for
a key lost anyway (`test_sweep_keeps_an_open_handoffs_tree_alive`); the
migration states its conservative legacy rule instead of claiming to
match recovery.
- [x] Review bot's optional advice folded in: the sweep's orphan release
fences on the task it already holds instead of re-reading it, the result
tool's refusal branch has a test per terminal outcome, and the orphan
threshold's rationale sits with its constant.
- [x] Review round (CodeRabbit, third pass): the migration backfills
`resultSequence` before dropping `result` (no such rows exist anywhere,
the flag was never created, but the migration is now correct on its
own); recovery uses the chat's recorded turn id to own the rows after a
drained clarification unless a later turn ran
(`test_answer_is_bounded_by_the_turn_the_worker_still_holds`, three
endings).
- [x] Review round (Sentry, second): a nested handoff can outlive the
tree ledger's turn-lifetime key, so promotion and pickup of background
work reopen a closed tree (`reopen_closed_tree`); a spent tree still
refuses a nested continuation, which is the spend budget acting
(`test_nested_continuation_reopens_an_expired_tree_but_not_a_spent_one`).
- [x] Review round (CodeRabbit, second pass): the tool consults the
record for every sub instead of the loaded message page, reports an
unrecorded background child as running, and carries the recorded turn's
tool calls so `sub_tool_call_count` and the file manifest are scoped to
that turn (real-database tests). Its first finding, an empty
intersection in `CopilotPermissions.merged_with_parent` reading back as
allow-all, is pre-existing and untouched by this PR; flagged for its own
fix.
- [x] Review round (Sentry): a clarification drained mid-turn no longer
hides the child's final answer; the answer row is recorded at completion
with the worker-held bound, recovery keeps the conservative bound
(`test_answer_is_bounded_by_the_turn_the_worker_still_holds`).
- [x] Review round (GitHub Copilot's five findings): two overlap Capy's
(dead-worker recovery, result-tool permission); a child still queued
past an inherited deadline is now closed as timed out (new real-database
test); the continuation-node refund is not reproducible (the
continuation row is an answer row, so the queue never treats it as a
spawned child); the six-hour staleness cutoff is the platform's bound on
every turn, left as is.
- [x] Review round (CodeRabbit's two findings): a launch without the
result tool in the allow-list is refused, and the child inherits the
parent's enforced approval mode; both with real-database tests.
- [x] Review round (Capy's three findings, each with a real-database
test): the sweep recovers a child whose worker died and leaves a live
one alone; a cancelled child turn cancels and suppresses the grandchild
it launched; the result tool returns the child's own answer when its
chat has moved on.
- [x] Real-Postgres/Redis handoff suite rewritten for the new rules: the
wake runs under the launching turn's envelope and names the child
instead of carrying its text; a spent subtask tree does not stop the
chat; a cancelled queued child gives its node back; the child may
delegate; the session state reports `pending`; recovery keeps a
delivered continuation. The deadline, delivery-window and
expiry-recovery tests are removed, and the inherited-deadline tests stay
(`test_worker_enforces_an_inherited_deadline_even_during_silent_work`,
the worker-clock test, the claimed-admission-failure test).
- [x] In the Linux test image against a private Postgres/Redis/RabbitMQ
stack: the background handoff, turn-queue, executor, session-waiter,
sub-session, chat-route, scheduler and stream-registry suites pass (918
in the first run, the three old-envelope assertions then rewritten and
the handoff suite re-run green).
- [x] pyright 0 errors on the touched backend files; black/isort/ruff
clean; private Prisma client regenerated for the schema change; OpenAPI
export matches the committed `openapi.json`.
- [x] Frontend: `tsc --noEmit` clean; eslint 0 errors (the four warnings
are pre-existing); oxlint clean; the copilot lifecycle, ToolChain and
helper Vitest suites pass, including the new cases that a background
card keeps polling past the foreground cap and that the page polls while
`background_pending` holds.
- [ ] Smoke-test with a live provider: the review bot's sandbox cannot
run Otto (revoked token); the behaviour is covered by the real-database
handoff suite.

#### For configuration changes:

- [x] `.env.default` is already compatible.
- [x] `docker-compose.yml` is already compatible.
- [x] Configuration changes and rollout: apply the migration, deploy
compatible API, database-manager, scheduler and workers, keep the flag
off until then.

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-04 11:01:15 +00:00
Nicholas Tindle aef0f991a8 feat(backend): add Tencent Hy4 to the LLM catalog (#16605)
Tencent released the official Hy4 on OpenRouter today (2026-11-04),
succeeding Hy4 Preview, which stays in the catalog.

- `tencent/hy4` via OpenRouter: 1,048,576 context, 65,536 max output,
tools and reasoning supported
- Live price $0.417/$1.2505 per 1M, $0.021/1M cached input; credits at
the standard 1.5x margin (62.55/187.575, cache read 3.15), flat tier 2
- New `LLMModel.TENCENT_HY4`, pinned-rate test, and the block docs model
lists
- `check_openrouter_prices.py` reports MATCH for the new entry
2026-11-04 07:10:22 +00:00
Abhimanyu YadavandClaude Opus 5.5 d981a641e3 refactor(frontend): remove the Tailwind palette; roles are the only colours (SECRT-3097 C4) (#16612)
### Why / What / How

**Why:** SECRT-3097 follow-up, PR C of the theming plan (item C4). PRs A
(#16511), B (#16513) and D (#16556) moved every app colour onto the role
tokens, but Tailwind's palette was still compiled from
`tailwind.v3-preset.ts`, so `bg-red-500` still worked and the "one
colour system" goal was held only by lint. This PR removes the palette
and locks the door.

**What:** colour systems 4 → 1. The roles in `src/app/tokens/theme.css`
(one `:root` role block, mapped by `globals.css`'s `@theme inline`) are
now the only colours Tailwind knows. `tailwind.v3-preset.ts` is gone,
`--color-*: initial` resets Tailwind's colours, and four colour lint
rules are at error with a baseline of 0.

**How:**
- **Preset deleted, nothing else moves.** The preset was more than a
palette: it carried all of Tailwind 3's theme (shadows, blur,
breakpoints, z-index…) that keeps every class at its Tailwind 3 value.
Those keys move verbatim into `tailwind.config.ts` as
`TAILWIND_3_THEME`; only `colors` (and `APP_COLORS`/`withAppColors`) is
dropped.
- **Proved on the compiled CSS.** I compiled `globals.css` with the
app's PostCSS config on `dev` and on this branch and diffed them rule by
rule (then again on the two production builds' CSS bundles). Every
removed rule either has an identical replacement or is listed under
"Visual changes" below.
- **Screenshots** of every page the scripted harness reaches
(`scripts/screenshot-pages.ts`, threshold 0) at 1440×900 and 390×844,
`dev` vs this branch.

### Changes 🏗️

**Palette removal**
- Delete `tailwind.v3-preset.ts` (1,390 lines). Its non-colour Tailwind
3 theme moves into `tailwind.config.ts` unchanged; `presets` is gone.
- `theme.css` starts with `@theme { --color-*: initial; }`, before any
colour is registered, so a palette class generates nothing.
- `bg-message-table-header` read
`theme(colors.white)`/`theme(colors.zinc.50)`; now
`surface`/`surface-muted` (same values).

**Last palette uses, snapped or kept exact**
- **User-picked palettes** (all already in `ds-colour-exempt.json`): the
expert colour picker (`ColorStep/helpers.ts`), folder artwork
(`FolderIcon.tsx`) and sticky notes (`StickyNoteBlock.tsx`). 225 palette
classes become the exact same hex as arbitrary values (`bg-rose-300` →
`bg-[#fda4af]`), so their CSS is byte-identical. The expert's saved id
(`rose-300`) is unchanged.
- **Two new roles** for status text on the always-dark fills (toast,
terminal, tooltip), which replaced the last three per-class exemptions:
- `success-inverse` 110 231 183 (emerald-300, same value): Text/Icon
`success-light` tone.
- `error-inverse` 242 105 105 (red-400, same value): terminal exit code,
and the library card's error tooltip (was red-300, see below).
- **Streamdown** (vendored chat markdown) still writes seven palette
classes in its mermaid error box and image hover veil. A block at the
end of `theme.css` draws them inside `:where([data-streamdown] *)` only,
with the palette's values (a role where one is identical). Its seven
shadcn alias names stay as before (role aliases, for its markup only, as
the plan allows).
- `KitSearchSkeleton` shimmer read `theme(colors.zinc.*)`; now
`rgb(var(--color-surface-hover|surface-muted))` (same values).
- `MultiSelect`: `scrollbar-thumb-muted-foreground` (a shadcn alias the
rules did not see) → `scrollbar-thumb-foreground-tertiary` (same
colour).
- `.storybook/preview.tsx`: `bg-background` → `bg-canvas` (same colour).
- The tour backdrop shader defaulted to `var(--color-sky-700)`, a
variable that never existed at runtime, so the canvas fell back to
black. It now reads `foreground-base` and turns a role triplet into
`rgb()`.
- `scrollbar-track|thumb|corner-*` read `transparent` from the palette
(`scrollbar-track-transparent`, 17 uses); they now accept it as a
keyword. The compiled-CSS diff caught this one.

**Lint (all `error`, baseline 0, stories included, off only for tests
and `__generated__`)**
- `design-system/no-raw-palette-classes` (renamed from
`no-palette-colours`).
- `design-system/no-shadcn-alias-colours` (renamed from
`no-alias-colours`).
- `design-system/no-hex-colours` (unchanged; it now leaves a hex that is
a class's whole arbitrary value to the rule below, so it isn't reported
twice).
- **New `design-system/no-arbitrary-colour`:** a colour class whose
arbitrary value is a colour literal (`bg-[#..]`, `text-[rgb(..)]`,
`border-[color:hsl(..)]/40`, `[color:#fff]`), with the matching role in
the message. It reads `ds-colour-exempt.json`. A role through `var()` is
allowed. Colours inside shadows and gradients are not in scope. The
wrapped shadcn `no-arbitrary-values` now leaves arbitrary colours to it.
- The colour rules (and the alias codemod) now also read
`scrollbar-thumb|track|corner-*` classes.
- No suppression comments used the old rule names.
`design-system-baseline.json` is unchanged (it had no colour entries).

**Tests**
- New `src/components/styles/__tests__/roles.test.ts`: every role in
`color-roles.ts` has its value in the one `:root` block of `theme.css`
and an `@theme inline` entry. It compiles the real stylesheet:
`bg-<role>` exists for every role, and `bg-red-500`, `bg-zinc-100`,
`bg-white`, `bg-black` generate nothing. It also checks the reset comes
before any colour is registered, the preset file is gone and the config
has no `colors`/`presets`.
- `tailwind3-compat.test.ts`: its palette fixtures become
roles/arbitrary hex (same assertions), plus a "generates nothing" check
for palette classes and a check for `scrollbar-track-transparent`.
- `design-system-contract.test.ts`: "keeps the raw palette frozen" (it
imported `APP_COLORS`) becomes "has no raw palette".
- `design-system.test.ts`: renamed rules, `no-arbitrary-colour` cases, a
scrollbar alias case.

**Docs:** `docs/engineering/design-system.md`, `frontend/AGENTS.md`,
`CONTRIBUTING.md`, and the comments in `globals.css`/`theme.css`.

**Size:** +1,612 / −1,708 (3,320 lines, over the ~3,000 guide; kept as
one PR as asked). About 2,320 of those are the preset: 1,390 deleted,
930 of them moved into `tailwind.config.ts` unchanged.

**Counts (success criteria)**

| Measure | Before | After |
|---|---|---|
| Colour systems | roles + compiled palette (+ alias names for
streamdown) | roles only (streamdown's alias names point to roles) |
| `tailwind.v3-preset.ts` | 1,390 lines | gone |
| Palette rules in the compiled stylesheet | 198 | 0 (streamdown's 7
classes are drawn by a scoped block in `theme.css`) |
| Palette uses in `src/` (outside test fixtures and the two stories that
show raw classes on purpose, as iframe sample HTML and a "don't" code
example) | 228 classes (225 in the three user-picked palettes, 3 on dark
surfaces) + 7 palette references (`theme(colors.*)` ×3,
`var(--color-sky-700)` ×2, and 2 in `tailwind.config.ts`) | 0 |
| Colour entries in `design-system-baseline.json` | 0 | 0 |
| Colour lint rules at error | 3 | 4 |

### Visual changes

No layout or text moves. One small colour change is visible, so this PR
is labelled `pixel-change`. There are **no bigger visual changes**.

**Small (slight shade shifts)**
1. **Library, workflow card error tooltip** (hover a card whose last run
failed): error text #F69999 → #F26969 (red-300 → red-400, now
`error-inverse`, the same red the terminal's exit code uses). The page
captures don't reach this hover state, so this is the real tooltip
classes rendered with each build's CSS:
<img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/library-tooltip-error.png"
width="700">
2. **Tour (`/tour/chat`) backdrop dots:** dot colour #000000 → #141414
(`foreground-base`), drawn at ≤ 14% opacity. The darkest dot pixel goes
from 231 to 232 (out of 255): not visible. Zoomed, left before, right
after:
<img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/tour-dots-zoom.png"
width="500">

**Unchanged by construction (identical compiled CSS), not reachable by
the scripted capture:** the expert colour picker on `/raise` (later
step), sticky notes in the builder, toast/terminal green text, terminal
exit code, streamdown's mermaid error box and image veil, and the
scrollbars.

**Page screenshots** (`dev` vs this branch, both production builds with
`NEXT_PUBLIC_PW_TEST=true`, signed-in user with one library folder; 32
captures, 25 identical at threshold 0). The other 7 are noise that has
nothing to do with this PR: comparing two captures of this same branch
gives the same diffs.

| Page | 1440×900 | 390×844 | Changed px (1440 / 390) | Why |
|---|---|---|---|---|
| `/build` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__build__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__build__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__build__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__build__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__build__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__build__mobile.png)
| 0 / 0 | identical |
| `/copilot` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__copilot__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__copilot__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__copilot__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__copilot__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__copilot__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__copilot__mobile.png)
| 756 / 686 | Otto avatar only: it is randomised per load (same diff,
783 px, between two captures of this branch) |
| `/home` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__home__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__home__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__home__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__home__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__home__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__home__mobile.png)
| 0 / 714 | Otto avatar only (randomised per load, as above) |
| `/library` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__library__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__library__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__library__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__library__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__library__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__library__mobile.png)
| 0 / 0 | identical |
| `/login` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__login__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__login__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__login__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__login__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__login__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__login__mobile.png)
| 0 / 0 | identical |
| `/marketplace` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__marketplace__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__marketplace__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__marketplace__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__marketplace__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__marketplace__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__marketplace__mobile.png)
| 0 / 0 | identical |
| `/raise` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__raise__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__raise__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__raise__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__raise__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__raise__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__raise__mobile.png)
| 41909 / 1957 | live animated shader; never holds still on either build
|
| `/reset-password` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__reset-password__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__reset-password__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__reset-password__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__reset-password__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__reset-password__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__reset-password__mobile.png)
| 0 / 0 | identical |
| `/settings/account` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_account__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_account__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_account__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_account__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_account__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_account__mobile.png)
| 0 / 0 | identical |
| `/settings/api-keys` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_api-keys__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_api-keys__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_api-keys__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_api-keys__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_api-keys__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_api-keys__mobile.png)
| 0 / 0 | identical |
| `/settings/integrations` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_integrations__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_integrations__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_integrations__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_integrations__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_integrations__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_integrations__mobile.png)
| 0 / 0 | identical |
| `/settings/profile` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_profile__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_profile__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_profile__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__settings_profile__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__settings_profile__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__settings_profile__mobile.png)
| 0 / 0 | identical |
| `/signup` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__signup__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__signup__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__signup__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__signup__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__signup__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__signup__mobile.png)
| 0 / 0 | identical |
| `/team` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team__mobile.png)
| 0 / 0 | identical |
| `/team/autopilot` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team_autopilot__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team_autopilot__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team_autopilot__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__team_autopilot__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__team_autopilot__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__team_autopilot__mobile.png)
| 0 / 0 | identical |
| `/tour/chat` |
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__tour_chat__desktop.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__tour_chat__desktop.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__tour_chat__desktop.png)
|
[before](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/before/app__tour_chat__mobile.png)
·
[after](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/after/app__tour_chat__mobile.png)
·
[diff](https://github.com/Abhi1992002/AutoGPT/blob/evidence/ds-theming-c-palette/c4/diff/app__tour_chat__mobile.png)
| 8000 / 984 | backdrop dots are placed with `Math.random()` (7,242 px
between two captures of this branch); dot colour itself moves ≤ 1/255 |

Library with the folder artwork (exact hex now), before / after:

<img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/before/app__library__desktop.png"
width="420"> <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/2dd2fa7e08182cdb2c11559eefe01a7b423598d8/c4/after/app__library__desktop.png"
width="420">

### Agents and large language models used

- Claude Code with Claude Opus 5.5 (`claude-opus-5-5`)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm format`, `pnpm lint` (design-system rules at error), `pnpm
types`: pass
- [x] `pnpm test:unit`: 13,149 passed. 5 tests in unrelated files (admin
users, copilot mode selector, onboarding intro card, suggested tasks,
team autopilot) timed out while the machine was overloaded; all 5 files
pass on a re-run (75/75)
- [x] Compiled `globals.css` on `dev` and on this branch and diffed
every rule (and again on both production builds' CSS bundles): only the
changes listed above
- [x] Before/after screenshots of 16 pages at 1440 and 390 with
`scripts/screenshot-pages.ts`, compared at threshold 0
- [x] `bg-red-500`, `text-zinc-600`, `bg-white`, `bg-black` compile to
nothing (`roles.test.ts`, `tailwind3-compat.test.ts`)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json`
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens (exempt
user-picked palettes keep exact hex through `ds-colour-exempt.json`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size` (no copy or markup changes here)
- [x] Before/after screenshots at 1440 and 390 px are attached
- [x] User-facing copy says workflow and Otto, never agent or graph (no
copy changes)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): `tailwind.config.ts` and
`.oxlintrc.json` only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 06:51:22 +00:00
Reinier van der LeerandClaude Opus 5.5 3a043ddc3c fix(backend): a run's output no longer deletes an earlier output with the same file name (#16343)
When two workflow runs store an output file under the same name, the
second is now stored beside the first as `output (1).mp4`. Before, it
deleted the first, and the first run's link went dead.

Resolves #16341

### Why / What / How

**Why:** A block that returns a file (a video from a Replicate model, a
generated image) stores it in the user's workspace at `/<filename>`.
When a later run produced a file with the same name, it deleted the
earlier run's file: the earlier output link answered 404 and the result
was gone. Some models give every output the same name
(`fictions-ai/autocaption` always returns `output.mp4`), so each run
destroyed the last one's result. The same happened inside one chat,
where two block runs saving one name under `/sessions/<id>/` replaced
each other.

**What:** A stored block output never replaces an existing file. When
its name is taken, it is stored beside the existing file as `output
(1).mp4`, `output (2).mp4` and so on, the way a browser names a second
download, and every earlier `workspace://` reference keeps resolving to
its own bytes. Writes that ask to overwrite are unchanged: an upload
with `overwrite`, the `write_workspace_file` tool's `overwrite`, a
same-named re-upload in a chat, and Otto's own working files.

**How:** `store_media_file` called
`WorkspaceManager.write_file(overwrite=True)`, which replaced the file
already at the path, retiring its row and deleting its blob. It now
passes a new `rename_on_conflict=True` instead. `write_file` looks the
path up once. When the path is taken, one query lists the numbered `name
(` siblings and the write takes the next number. If a concurrent write
takes that number first, the index refuses the insert and the write
moves on to the next one (two retries, as `overwrite` has). A renamed
write replaces nothing, so it hands the insert's storage-quota
transaction no file to retire. The number goes on the name as well as
the path, because the Files page and downloads show the name. A folder
per run would have left several rows all reading `output.mp4`.

Each run's output now stays until someone deletes it, so it counts
against the storage quota; before, the overwrite freed the previous
copy.

### Changes 🏗️

- `backend/util/workspace.py`: `write_file(rename_on_conflict=...)`, and
`_free_path`, which picks the next free `name (n).ext`.
- `backend/util/file.py`: `store_media_file` stores block outputs with
`rename_on_conflict=True` instead of `overwrite=True`.
- `backend/util/file_db_test.py` (new): three runs store `output.mp4`
through the real `store_media_file` and the database, and each keeps its
own bytes, as `output.mp4`, `output (1).mp4` and `output (2).mp4`; and a
run whose free name another write takes between the look and the insert
lands at the next name, through the storage-quota transaction, while the
other write's file stays live.
- `backend/util/workspace_test.py`: a write that loses the free name to
a concurrent write takes the next number and deletes nothing, and one
whose path is free keeps the caller's file name.
- `docs/engineering/workspace-media-architecture.md`: the new parameter
and the rule.

### Verified

On dev's code `file_db_test.py` fails because the first run's file id no
longer resolves (`FileNotFoundError`); with this change it passes. Five
mutations each turn a test red: `overwrite=True` restored in
`store_media_file`; the rename dropped from the lost-race retry, after
which the real-database test shows the other write's file retired; the
free-name search always answering `(1)`; a renamed write looking up a
file to replace; and a free path renaming the file anyway. Every test
module that imports `backend.util.workspace` or `backend.util.file`, or
names `WorkspaceManager` or `store_media_file`, passes (51 modules
behind one stack-starter test: 1,530 passed, 12 xfailed), apart from
`api/features/store/skill_db_test.py`, whose guard refuses this
machine's shared database and which runs on a fake workspace manager;
`util/architecture*_test.py` (92) and `blocks/test/test_block.py`
(3,738, 84 skipped) pass too. Pyright reports nothing on the changed
files. I did not run a workflow end to end on a live stack.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Three runs storing one filename keep three files, each readable by
its own id (real database)
- [x] A write that loses the free name to a concurrent write takes the
next number and deletes nothing
  - [x] Workspace, file, architecture and block suites pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-04 05:08:20 +00:00
Reinier van der LeerandClaude Opus 5.5 7c9bd21884 docs(platform): tell v1 migrators to create a new API key, since keys can't be edited (#16304)
The v1 → v2 migration guide now tells readers to create a new API key
with the v2 permissions they need, instead of adding those permissions
to their existing key, which the app cannot do.

Follow-up to #12206.

### Why / What / How

**Why:** Someone migrating an integration from v1 follows the guide's
checklist, opens the API keys page to add `RUN_AGENT` and `READ_RUN` to
their key, and finds no way to do it. Both API-key pages
(`/settings/api-keys`, and the old `/profile/api-keys`) only list,
create and revoke keys. `PUT /api/api-keys/{key_id}/permissions` exists
in the backend, but nothing in the frontend calls it.

**What:** Checklist step 1 now says to create a new key with the v2
permissions the calls need, plus `IDENTITY` (which `GET /me` requires),
check it with `GET /me`, keep the old key for the calls still on v1, and
delete it once nothing uses it. The "Permissions" bullet under "What
changes" no longer implies the old key can take new permissions.

**How:** The step links to "Create an API key" on the authentication
page, which already says a key's permissions can't be changed.

### Changes 🏗️

- `docs/platform/integrating/migrate-from-v1.md`: checklist step 1 and
the "Permissions" bullet.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Docs only; nothing to execute.

**Verified** on `origin/dev` `e2316d0328` by reading the source. No file
in `autogpt_platform/frontend/src` references the `UpdateKeyPermissions`
operation (the generated client is not tracked, so nothing can import
its hook), and nothing calls the legacy
`BackendAPI.updateAPIKeyPermissions` method
(`src/lib/autogpt-server-api/client.ts:442`). The settings and profile
API-key pages use only `useGetV1ListUserApiKeys`,
`usePostV1CreateNewApiKey` and `useDeleteV1RevokeApiKey`. The create
dialog offers every v2 permission: `CreateAPIKeyForm.tsx:71` renders
`PermissionsCheckboxGroup`, whose groups (`schema.ts:36`) put every
permission that `src/lib/api-key-scopes.ts` marks `v2` under "API v2".
`GET /me` requires `IDENTITY`
(`backend/api/external/v2/identity.py:26`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 03:30:16 +00:00
Abhimanyu YadavandClaude Opus 5.5 b3d680f628 refactor(frontend): collapse colours to one role token set (SECRT-3154) (#16511)
### Why / What / How

**Why.** The app named colours four ways: 47 role tokens, 27 shadcn
alias names (`bg-background`, `text-muted-foreground`, `border-border`,
…, 545 uses), the Tailwind palette (`text-orange-600`, `bg-gray-100`,
~400 uses) and hex literals (~900). Changing the theme could not change
the whole app, and a dark theme would have to be written four times.
This is PR A of the SECRT-3097 follow-up (SECRT-3154, plan items
C1+C2+C3): one colour system, the roles.

**What.** Every colour in `src/app`, `src/components` and `src/lib` is
now a role class (`bg-surface`, `text-orange-foreground`, `ring-sky/30`)
or, in a string, `rgb(var(--color-<role>))`. The raw values live in one
`:root` block in `src/app/tokens/theme.css`, registered by
`globals.css`'s `@theme inline` (the T3 Code layout: a later theme is a
block of values, no component edits). The alias names are gone from the
theme, and three lint rules at error with a baseline of 0 keep the other
systems out. Artwork, logos, chart series and user-picked palettes are
listed in `ds-colour-exempt.json` with a reason.

**How.**
- **Aliases (C1):** `scripts/ds/alias-to-role.ts` renamed each alias
class to the role it already aliased (545 renames, 182 files), keeping
variants, `!` and opacity, plus `var(--color-<alias>)` in inline styles.
Because each alias rendered its role, this moves no pixel:
`scripts/check-token-equivalence.ts --base <dev worktree>`, extended
with the 27 alias→role pairs, reports **123 pairs, 0 failing; 1,785
opacity swatches, 0 differing pixels**. streamdown's prebuilt markup
(scanned from `node_modules`) still uses seven shadcn names, so those
seven are defined in `theme.css`, each aliasing a role; app code cannot
use them (`no-alias-colours`).
- **Palette and hex (C2+C3):** `scripts/ds/palette-to-role.ts` applied
the reviewed table `scripts/ds/palette-map.json` (396 swaps): each
palette step names its role as text, fill and line, and says whether the
colour stays the same (`none`, 217 swaps), shifts a shade within its hue
(`slight`, 176) or changes hue (`bigger`, 3). Hex in UI code became role
variables by hand (xyflow edge arrow, tutorial icons, drag previews, the
block-menu drag image, a banner hover, loader keyframes, chat-table
glows, shadows).
- **New roles, only where repeated uses needed a colour no role had:**
`orange`, `orange-foreground`, `yellow`, `yellow-foreground`, `sky`,
`sky-foreground`, `indigo`, `pink`, `pink-foreground` (usage meters,
data types, category accents, admin diagnostics, run badges), and
`scrim` (the black behind dialogs and in shadows, which must stay dark
in any theme). Pale surfaces and borders of a hue are the role at an
opacity rather than more roles: `bg-orange/10` is exactly orange-50,
`bg-pink/10` exactly pink-50, and T3 derives its `-surface`s the same
way, so a dark theme gets them for free. Everything else snapped to an
existing role (rose → error, sky-50 → info-surface, yellow-50 →
warning-surface, teal → success/sky).
- **Lint:** `design-system/no-alias-colours`, `no-palette-colours` and
`no-hex-colours` (error, all of `src/**` including stories, ratcheted at
0). Each error names the replacement (the aliased role, or the
`palette-map.json` row). `ds-colour-exempt.json` is the only escape
hatch: `scripts/__tests__/colour-exempt.test.ts` fails a suppression
comment for these rules, an entry that matches no file, an allowed value
the file no longer uses, and any hex in a stylesheet other than
`theme.css`. `no-raw-colors` reads the same list and leaves palette and
hex to the new rules, so nothing is reported twice. 120 `no-raw-colors`
suppressions written for "this hue has no role" are gone.
- Dead code found on the way: the builder's `hexColor` for output
handles was passed to `<Icon color>`, which the handle's `text-handle`
class always overrode; it is removed (no pixel change).

### Changes 🏗️

- `src/app/tokens/theme.css` (new): the one `:root` block of colour
roles (57, 10 new), the `.dark` smooth-ring override that already
existed, and streamdown's seven alias names.
- `src/app/globals.css`: imports the token file; `@theme inline`
registers the roles only (27 alias registrations removed); remaining raw
colours (default ring colour, glows, loader keyframes, shadow) now read
roles.
- 300 call-site files: alias and palette classes swapped for roles
(mechanical; see the scripts). No Text/Icon/Button atom internals
changed beyond colour-token swaps (`compact-variants.ts`; the shared
`styles/tones.ts` from #16513 keeps its `success-light` emerald, now
exempt there).
- `scripts/ds/` (new): `alias-to-role.ts`, `palette-to-role.ts`,
`palette-map.json` (the reviewed table), `colour-exempt.ts`,
`colour-utilities.ts`.
- `ds-colour-exempt.json` (new): 31 entries, each with a reason. Whole
files: the token file, the vendored Tailwind 3 reset, Notion avatar
artwork, empty-state illustrations, the AutoGPT logo, the glass orb,
confetti, the dither shader, Otto's avatar aura and greeting orb, the
memory-graph canvas, recharts charts (SVG attributes, where CSS
variables do not resolve), the React-artifact iframe document, the MCP
OAuth callback page, the tour's scripted documents (support-ticket
numbers that read as hex), sample artifacts in two stories, and three
user-picked palettes (expert colour picker, folder colours, sticky
notes). Single values: chart series in the expert overview and execution
analytics, the avatar fallback palette, the onboarding cover's baked-in
backdrop, a spinner mask, the code block's dark fallback,
`text-emerald-300` / `text-red-300` / `text-red-400` on always-dark
surfaces (the error and success roles drop below contrast there until
the dark theme gives them dark values), and the overview story's "don't"
examples.
- `scripts/eslint-rules/design-system.mts`, `.oxlintrc.json`: the three
rules; role advice now reads `theme.css`; `no-raw-colors` defers
palette/hex and honours the exempt list. Baseline: `styles/tones.ts`
`no-raw-colors` 1 → 0, nothing raised.
- Tests: `colour-exempt.test.ts` (new); patch-coverage tests for the
changed JSON renderer, usage bar, execution-path badge and builder type
colours; `design-system.test.ts` (rule cases), `theme-aliases.test.ts`
(only streamdown's names remain, each renders its role; dropped names
generate nothing), `design-system-contract.test.ts` (reads `theme.css`),
`tailwind3-compat.test.ts` (ring default reads `--color-info`),
`edgeStore.test.ts` (marker colour). No test deleted.
- `src/components/styles/color-roles.ts` + Tokens/Colors story: the new
Accents group and `scrim`; the "shadcn names" story is removed with the
names.
- `scripts/check-token-equivalence.ts`: the alias→role pairs, so it
proves the rename against any base.
- Docs: `docs/engineering/design-system.md` (Tokens section) and
`frontend/AGENTS.md` (Taste → Colour).
- Not in this PR: `tailwind.v3-preset.ts` is untouched (PR C deletes it;
streamdown's red classes and the exempt user palettes still need it
until then), no dark values, `forcedTheme` unchanged, no theme switcher,
no Storybook toggle. CODEOWNERS is unchanged (it is admin-owned);
`src/app/tokens/`, `ds-colour-exempt.json` and `scripts/ds/` could join
the design-system block there.
- Size: 315 files, +2,932/−1,320 (4,252 lines), over the ~3,000 guide;
776 of those lines are the reviewed `palette-map.json` table and most of
the rest are one-token swaps. Kept as one PR as asked.

### Visual changes

Refreshed after merging `dev` 606cb1b, which includes #16513 (Text, Icon
and Button variants), so before = `dev` 606cb1b and after = this branch
at 1ae437b. Both are production builds (`NEXT_PUBLIC_PW_TEST=true`) on
the same seeded database (17 marketplace workflows, the skills catalog,
one library workflow, one hired expert, an admin user), captured with
`scripts/screenshot-pages.ts` at 1440x900 and 390x844 and compared with
pixelmatch at threshold 0. 43 routes, 86 screenshots: **63 identical, 23
with changed pixels**. Each one was checked by eye: the colour changes
are the ones the mapping already listed (the merge with #16513 added
none), and the rest is capture noise (clock, animation, the sidebar
blur-in, an image loading). All images, diffs and crops:
[`evidence/secrt-3154`
r2](https://github.com/Abhi1992002/AutoGPT/tree/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2)
([compare
table](https://github.com/Abhi1992002/AutoGPT/blob/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/compare.md));
the first round, against `dev` e681d74, is in the same branch.

| Route | 1440 changed px | 390 changed px | Result |
| --- | --- | --- | --- |
| `/login` | 0 | 0 | No visible change |
| `/signup` | 0 | 0 | No visible change |
| `/reset-password` | 0 | 0 | No visible change |
| `/tour/chat` | 8378 | 934 | Noise: the animated dot-grid background;
the title gradient is the same colour. |
| `/home` | 0 | 0 | No visible change |
| `/copilot` | 0 | 0 | No visible change |
| `/marketplace` | 3966 | 3965 | Small: sky skill-tile icon backgrounds
sky-50 → info-surface. |
| `/marketplace/agent/autogpt/business-ownerceo-finder` | 1283 | 1005 |
Small: the info card's gradient frame ends indigo-200 → indigo/30
(near-identical). |
| `/marketplace/creator/autogpt` | 1331 | 1119 | Small: the creator
card's gradient frame ends indigo-200 → indigo/30 (near-identical). |
| `/marketplace/skills` | 47046 | 23995 | Small: "Operations"/"Support"
category chips sky-50 → info-surface; the rest of the 1440 count is the
sidebar blur-in (noise). |
| `/marketplace/search?searchTerm=lead` | 11255 | 11254 | Small:
"Operations"/"Support" category chips sky-50 → info-surface. |
| `/library` | 0 | 9164 | Noise (390 only): 1–4-level antialiasing
across the one workflow card (it has an image, so no placeholder
gradient is drawn). Placeholder gradients for workflows without an image
move to role tints (slight). |
| `/library/agents/4c71847c-b6c7-4ceb-afcb-a7aed4d07e11` | 0 | 0 | No
visible change |
| `/library/skills` | 0 | 0 | No visible change |
| `/library/followups` | 0 | 0 | No visible change |
| `/team` | 0 | 0 | No visible change |
| `/team/autopilot` | 0 | 0 | No visible change |
| `/team/ea5951f8-8f57-4f97-8c59-336b81388c27` | 0 | 0 | No visible
change |
| `/build` | 0 | 0 | No visible change |
| `/build?flowID=f6138aaf-4f85-48d5-b905-db11a2ff82f3` | 18 | 0 | Small:
the edge arrowhead, #555 → #505057. |
| `/artifacts` | 23563 | 0 | Noise (1440 only): the base run caught the
sidebar mid blur-in. |
| `/raise` | 17935 | 3165 | Animated dither shader never holds still
(both runs); compared by eye: no change. |
| `/settings/profile` | 0 | 0 | No visible change |
| `/settings/account` | 0 | 0 | No visible change |
| `/settings/api-keys` | 0 | 0 | No visible change |
| `/settings/integrations` | 0 | 0 | No visible change |
| `/settings/billing` | 0 | 0 | No visible change; the page shows
"Application error" in **both** builds on this sandbox (no billing
provider configured) |
| `/settings/creator-dashboard` | 0 | 0 | No visible change |
| `/settings/memory` | 0 | 0 | No visible change |
| `/settings/bots` | 0 | 0 | No visible change |
| `/profile/credits` | 0 | 0 | No visible change; 404 in both builds
(the page moved to `/settings/billing`) |
| `/profile/dashboard` | 0 | 0 | No visible change |
| `/admin/dashboard` | 0 | 0 | No visible change |
| `/admin/diagnostics` | 3482 | 2192 | Small: "Queued in RabbitMQ" dot
yellow-100/500 → yellow/30 and yellow (near-identical); info-list
headings keep their colours (orange-700 → amber-700 is #B46000 →
#B45309); the rest is the "Last updated" clock. |
| `/admin/platform-costs` | 0 | 0 | No visible change |
| `/admin/spending` | 0 | 0 | No visible change |
| `/admin/execution-analytics` | 0 | 0 | No visible change |
| `/admin/memory` | 6513 | 6492 | **Bigger:** "Ratification" button teal
→ green (success); "Nightly batch" button indigo-700 → indigo-500
(lighter). |
| `/admin/bots` | 0 | 780 | Noise (390 only): recharts lays its axis
line out a few px apart between runs; the chart file is exempt and
unchanged. |
| `/admin/marketplace` | 0 | 0 | No visible change |
| `/admin/block-cost-estimates` | 0 | 0 | No visible change |
| `/admin/users` | 756 | 0 | Noise (1440 only): the route redirects to
Home in this sandbox, and the base run caught the Otto avatar before it
loaded. |
| `/admin/rate-limits` | 0 | 0 | No visible change; redirects to Home in
both builds |

**Bigger visual changes** (a colour changing completely):
- **Admin → Memory** (`/admin/memory`): "Ratification" button teal →
green (`bg-teal-600`/`700` → `bg-success`/`success-foreground`);
"Nightly batch" button indigo-700 → indigo-500 (`bg-indigo`, lighter;
hover indigo/90).
- **Settings → Billing** (`/settings/billing`, plan card cycle toggle):
the "Save %" gradient text ends in sky instead of teal (`to-teal-500` →
`to-sky`). Not captured: the page errors on this sandbox in both builds.
- **Admin → Diagnostics** (`/admin/diagnostics`, with orphaned runs
selected): the "(N orphaned)" count inside the red "Stop selected"
button goes pale orange → white at 80% (`text-orange-200` →
`text-action-foreground/80`).

**Small visual changes** (shade shifts within the same hue; most only
show with data or on hover):
- Shared: Navbar activity dropdown yellow-600 → yellow-700
(`yellow-foreground`), its hover grey #F3F4F6 → #EFEFF0; attention card
caution icon yellow-600 → yellow-700; briefing card gradient purple-800
→ violet-700; pending-reviews and trial-card text #1F1F20 → #141414;
dialog/sheet backdrops unchanged (black is #141414 in this app, now
`scrim`).
- `/marketplace`, `/marketplace/skills`, search: sky chips/tiles sky-50
→ blue-50 (`info-surface`), text sky-800 → sky-700; gradient frames
indigo-200 → indigo/30.
- `/team`, `/team/[expert]`: setup status and workflow-card chips sky-50
→ blue-50, sky-600/800 → sky-700.
- `/settings/creator-dashboard`: "Needs changes" pill and delete action
rose → red (`error-surface`, `error-foreground`, `error-hover`), stats
tile sky-50 → blue-50.
- `/profile/credits`: pending-change banner and tier notice
violet-800/900 → violet-700 (`accent-strong`), border violet-300 →
brand/40.
- `/admin/diagnostics`, `/admin/platform-costs`,
`/admin/execution-analytics`, `/admin/marketplace`: orange-700/800/900 →
amber-700 (`warning-foreground`), yellow-600/800/900 → yellow-700,
pink-600/800/900 → pink-700; tinted rows orange-50/100 → orange/10, /30.
- `/admin/spending`: MAX tier chip indigo-100/700 → indigo/15 and
violet-700 (`accent-strong`).
- `/admin/memory`: invalidation row border rose-200 → error/20.
- `/build`: node status rings yellow/orange-300 → yellow/orange at 70%,
execution badges border yellow/orange-400 → -500, yellow-800 →
yellow-700, review badge surface yellow-50 → amber-50; edge arrowhead
#555 → #505057; tutorial icons #52525B → #68686F; block drag preview
#F4F4F5/#27272A → #EFEFF0/#3E3E43.
- `/library`: placeholder gradients for workflows without an image move
to role tints; Files drag ghost greys move to roles.
- `/home`: "needs you" work item yellow-50/200 → amber-50/200.
- `/copilot`: orbit loader balls #18181B/#A1A1AA → #2C2C30/#ADADB3; chat
table violet glow #8B5CF6 → brand #7733F5; notification banner hover
#FFE4BF → orange/25 (≈ #FFE6BF); token devtool dot sky-400 → sky-500.
- `/marketplace` category chips: shadow tint #101828 → #141414 at 5–8%.

<details><summary>Before / after / diff images for the changed
pages</summary>

| Page (1440) | Before | After | Diff |
| --- | --- | --- | --- |
| `admin_memory` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__admin_memory__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__admin_memory__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__admin_memory__desktop.png"
width="260"> |
| `admin_diagnostics` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__admin_diagnostics__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__admin_diagnostics__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__admin_diagnostics__desktop.png"
width="260"> |
| `marketplace_skills` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_skills__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_skills__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_skills__desktop.png"
width="260"> |
| `marketplace_search_searchTerm_lead` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_search_searchTerm_lead__desktop.png"
width="260"> |
| `marketplace` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace__desktop.png"
width="260"> |
| `marketplace_agent_autogpt_business-ownerceo-finder` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="260"> |
| `build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/before/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/after/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/diff/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="260"> |

| Crop | Before → after |
| --- | --- |
| `admin_memory__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_memory__desktop.png"
width="520"> |
| `admin_diagnostics__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_diagnostics__desktop.png"
width="520"> |
| `marketplace_skills__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__marketplace_skills__desktop.png"
width="520"> |
| `marketplace_agent_autogpt_business-ownerceo-finder__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__marketplace_agent_autogpt_business-ownerceo-finder__desktop.png"
width="520"> |
| `build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__build_flowID_f6138aaf-4f85-48d5-b905-db11a2ff82f3__desktop.png"
width="520"> |
| `admin_bots__mobile` | <img
src="https://raw.githubusercontent.com/Abhi1992002/AutoGPT/486ba658feccd8eb7ad72dd9a4a1cf97454c8bea/secrt-3154/r2/crops/app__admin_bots__mobile.png"
width="520"> |

</details>

### Agents and large language models used

- Claude Code with Claude Opus 5.5 (`claude-opus-5-5`)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm lint` (design-system rules at error), `pnpm types`, `pnpm
format` clean
- [x] `pnpm test:unit`: 12,940 passed locally, all four CI shards green;
the 5 failures and 5 worker exits in that run were memory pressure on
the sandbox (OOM killer), and the four failing files pass on their own
(`stream-drift-content`, `stream-drift-guards`,
`creator-dashboard/main`, `GenericTool`); CI runs the full suite
- [x] `scripts/check-token-equivalence.ts --base <dev worktree>`: 123
pairs, 0 failing; 1,785 opacity swatches, 0 differing pixels
  - [x] `NEXT_PUBLIC_PW_TEST=true pnpm build` succeeds on the branch
- [x] Before/after screenshots of 43 routes at 1440x900 and 390x844 with
`scripts/screenshot-pages.ts` (dev 606cb1b vs this branch at 1ae437b,
same seeded database), compared at pixel threshold 0 (results above)
- [x] CI green on `1ae437b` (after merging `dev` with #16513): lint,
types, knip, the four unit-test shards, both end-to-end suites,
tooltip-browser and codecov all pass (unit shard 3 needed one rerun for
the `chat-surface-parity` "SSL destruction" teardown flake that `dev`
has too)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json`
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours outside `ds-colour-exempt.json`; colours use role tokens
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; `className`
changes here are colour-token swaps only
- [x] Before/after screenshots at 1440 and 390 px are attached
- [x] User-facing copy says workflow and Otto, never agent or graph (no
copy changed)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (none)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- conductor-workspace-link -->

---

[Open workspace in
Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/208)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-04 02:53:08 +00:00
8fa5a699b2 feat(platform): wake Otto after opt-in background subtasks (#15733)
### Why / What / How
[SECRT-2813](https://linear.app/autogpt/issue/SECRT-2813): background
helpers were lost when the parent turn closed its SDK client. #15127
made native helpers foreground-only; that safeguard remains in place.
This adds explicit `run_sub_session(background=true)`. A fresh subtask
runs through the durable queue after Otto finishes its initial reply.
Completion, failure, cancellation, or timeout wakes the original chat
for one **budgeted, tool-free summary**. Foreground remains the default.
A durable handoff record tracks acceptance, outcome, and follow-up
admission. Stable IDs and execution claims prevent duplicate model runs;
scheduler reconciliation recovers missed dispatch and delivery. Both
stages retain the original budget tree, billing context, credentials,
owner, and organization/team/expert scope. ### Changes 🏗️ - Add
`copilot-background-subtasks`, **off by default**, and an additive
`ChatBackgroundTask` migration. No new service or environment variable.
- Bound the child to 10 minutes including queue time and its follow-up
to 2 minutes, without extending an inherited deadline. Recheck the
original budget and permissions at promotion and pickup, including block
restrictions. Child and summary share one admitted background node.
Existing spend limits meter settled usage; they **do not reserve
concurrent spend or guarantee zero overshoot**. - Show pending status, a
subtask link, and Stop. The session endpoint reports
`background_outcome` for a child chat and `background_pending_until` for
a parent chat from the task record, so cards and the parent's late-reply
polling do not depend on the launch or acceptance rows staying within
the 50-row transcript page. Cards poll until that outcome lands, with
unknown status at expiry if needed. Both chat runtimes refresh for late
replies; missing message history during session refresh safely disables
polling, and unrelated workflow-result or skipped-run notices do not
stop a pending follow-up refresh. Synthetic turns have a readable label
and stay out of user-message analytics. - Child Stop reports
cancellation; stopping the launching parent turn also suppresses its
automatic wakes. Stopping a queued follow-up atomically saves one
cancellation notice and releases the session, including when later user
messages have been persisted. Saved execution identity and atomic,
scoped cleanup protect successor turns even after Redis loss. Deleted
handoffs release only their own queued turn; admission clears the
previous turn ID before a successor can be dispatched. Execution
identity remains separate from a shared analytics trace. - Recover
delivery by saved wake ID. Busy chats defer delivery and receive a
terminal notice if the delivery window expires. Recovery preserves a
follow-up when queue cancellation loses to promotion or newer work. A
connection change before delivery produces one static notice at the next
idle opportunity; scope changes still suppress delivery. The scheduler
holds its single-job slot until reconciliation finishes, preventing
overlapping sweeps. - Recover completed child work, and a delivered
follow-up, after a failed outcome write only from a matching
owner/claimed-turn success record; preserve committed cancellation and
reject missing or mismatched evidence. The worker that still holds a
child's turn takes the answer after a clarification it drained mid-turn;
recovery stops at the next user row. Distinguish
cancellation/deadline/failure notices, and invalidate the parent chat
cache after notice commit. A handled execution deadline finishes
normally at the queue boundary; operation and cleanup errors still
propagate. Duplicate pickup leaves the active turn intact. Admission
refusals close with the actual reason; unreadable background limits
retain the node for its summary, and background refusals omit
approval-answer copy. - Fence dispatch cleanup by turn ID; admission
clears the recorded turn identity, so one admission records one turn and
a dispatcher that stalled past orphan recovery refuses itself instead of
overwriting its successor. A scheduler stop names the turn it expires,
and the worker ignores a stop whose turn is no longer running. A new
HTTP send that loses admission returns a retryable 409 before saving the
message; a racing double send of the same text still rides the first
send's turn, as dev's #15918 made it. Scheduled one-shots retry busy
chats within existing limits; recurring schedules keep their next tick.
Preserve dev's scheduled-chat failure tracking and tracing. - Document
harness findings, lifecycle, budgets, and rollout in [the design
note](docs/engineering/background-subtask-delivery.md). Rollout: apply
the migration, deploy compatible API/database-manager/scheduler/workers,
then enable the flag for test accounts. Keep it off while workers are
mixed. Disabling it prevents new opt-ins while accepted work drains.
Review focus: durable handoff and cancellation races, original
scope/budget preservation, and the pending/Stop/follow-up experience.
### Agents and large language models used Codex with gpt-6-astra,
running in T3 Code. Claude Fable 5.1 (Claude Code, in T3 Code) for the
dev merge, its validation and the review rounds after 2026-11-03. ###
Checklist 📋 #### For code changes: - [x] I have clearly listed my
changes in the PR description. - [x] I have made and executed a test
plan: - [x] Reproduce native background loss using the locked Claude SDK
(0.2.161), installed CLI (2.1.303), fake credentials, and a local
scripted Anthropic server; verify the existing foreground regression. -
[x] Exercise 74 real Postgres/Redis handoff scenarios: parent finishes
first, single follow-up, duplicate pickup, busy parent, budgets, Stop,
timeout, lost metadata, owner/route rejection, and successor protection.
Reproduce expiry/promotion and cascade-deletion races before their
fixes, including missing Redis metadata and the
admission-before-dispatch window. All 396 focused
handoff/queue/background/admission/chat-route cases pass, including 74
real-database scenarios. Queued Stop reproduces the lost cancellation
reason before its fix; new controls cover later pending messages,
ownership, promotion, notice-write rollback, idempotency, and cache
visibility. Three existing real-database concurrent-cap/status-CAS cases
also pass. - [x] Pass 93 queue/background cases and 234
scheduler/async-bridge/reset cases. Reproduce malformed-envelope budget
refunds, misleading background refusal copy, and a scheduler job
returning while its coroutine still runs; verify their fixes and
normal-child/approval/credential-sweep controls. - [x] Pass the focused
worker/dispatch/scheduled-watch suites, 150 tracing/tool/sub-session
cases, and 5 worker execution-context cases. Negative controls fail when
promotion admission, worker admission, completion persistence, timeout
enforcement, or retained summary budget is removed. - [x] Reproduce a
handled deadline escaping the worker as `TimeoutError`; pass 32 timeout,
Stop, shutdown, and manager cases. The manager acknowledges handled
deadlines and user Stop, while real operation and cleanup timeouts
remain rejected. - [x] Pass all 119 selected background lifecycle/worker
cases on the latest patch, including 87 real Postgres/Redis scenarios.
This includes the strengthened timeout
outcome/session-release/single-notice assertions. Reproduce both
route-change silence and successful work becoming failed/timed_out after
an outcome-write failure; cover one notice, cache visibility,
busy-parent deferral, scope rejection, matching completion recovery, and
wrong-owner/turn, missing/failed/unclaimed record and cancellation
controls. - [x] Reproduce both schema-size CI failures, then pass all
333 schema/sub-session/background cases on the branch and its
prospective merge with dev `c31ec68f7d`. Trim only tool prose: registry
77,149 < 77,176; session wire 69,654 < 69,788. Existing budget limits
are unchanged. - [x] Pass frontend card/history/chat-lifecycle coverage
(82 cases) and notification/helper/chat-lifecycle coverage (102 cases),
including late delivery in both runtimes, idle-child recovery, expiry,
and declined Stop feedback. Reproduce the missing-history polling crash
from frontend CI, then pass all 34 helper/lifecycle/kickoff cases,
including omitted/null history with successful sends and replies in both
runtimes. Reproduce six missed-refresh cases when standalone
workflow-result or scheduled-run notices arrive before the follow-up;
pass all 82 polling/lifecycle/history/kickoff cases with coverage after
sharing the existing standalone-message classification. - [x] Validate
the SQL migration transactionally; export OpenAPI and regenerate the
frontend client. Use a temporary Prisma client and disposable,
task-owned Postgres/Redis services. Cleanup is verified: no task-owned
Postgres/Redis services remain after the latest successful run. - [x]
Run frontend format/lint/types, focused backend pyright, Black/isort,
and repository pre-commit checks including Ruff and secret scans. - [x]
Smoke-test with a live provider and all deployed services: the
`/dev-review` bot's local run on `1f0a1bc` (2026-10-23) sent a
background request, saw the approval card ("Run in background: Yes"),
got the parent's immediate reply, and received the follow-up turn
"Background subtask completed. Otto is following up." / "17 + 25 = 42"
after the parent turn closed, with the `ChatBackgroundTask` row at
`outcome=completed`, `followupState=finished`. - [x] Merge dev (542
commits, 11 conflicting files) and re-validate on the merge: every PR
backend module imports; pyright 0 errors on the 38 touched backend
files; black/isort/ruff clean; the OpenAPI export matches the committed
`openapi.json`. In the Linux test image against a private
Postgres/Redis/RabbitMQ stack: background handoff, turn-queue,
session-waiter, sub-session, executor utils/tracing/manager/processor,
chat routes, scheduler and tool-base suites pass (batches of 308, 501
and the patched files). Frontend: `tsc --noEmit` clean, 944 copilot
lifecycle/ToolChain/helper tests pass, prettier/eslint/oxlint clean on
the touched files. - [x] Review round on the merge (GitHub Copilot's six
findings, each with a test): session-level background state on `GET
/sessions/{id}` with a DB test and a route test; turn-bound scheduler
cancel with a manager test; the one-admission-one-record CAS, follow-up
success recovery and worker-held result selection with real-DB tests.
Re-ran the handoff, executor, route, queue, scheduler and sub-session
suites in the Linux image, plus tsc, eslint and the copilot Vitest
suites.
- [x] Two dev-drift fixes on the merge, each with a test: dev's #15918
expects an identical double send on a refreshed slot to ride the first
turn (the PR refused it with a 409), so `schedule_chat_turn` now
collapses only an identical trailing text and refuses anything else
before saving; and dev's `HandoffStatus` plus the React Compiler lint
rules needed `useSubSessionEffectiveStatus` to return `string | null`
and the poll cap to be keyed state instead of a setState inside the
effect. Local limitations: the full repository suite was not run. Full
backend types report four unrelated Windows/Prisma errors, and full-file
Ruff findings in queue/processor code are unchanged. Focused types and
pre-commit Ruff pass. Direct checks replace the local isort wrapper's
unsupported Poetry option and the type hook that runs from the
repository root. Spend overshoot from concurrent settled usage remains
an existing budget limitation. #### For configuration changes: - [x]
`.env.default` is already compatible. - [x] `docker-compose.yml` is
already compatible. - [x] Configuration changes and rollout are listed
above.

---------

Co-authored-by: gpt-6-astra (Codex) <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-04 01:07:03 +00:00
Abhimanyu YadavandClaude Opus 5.5 aba0ba606d refactor(frontend): one text-size scale; Tailwind's sizes reset (SECRT-3152) (#16556)
### Why / What / How

**Why:** SECRT-3097 follow-up, PR D of the theming plan (item T1,
SECRT-3152). The app had two type scales: Tailwind's
`text-xs`…`text-4xl` (about 1,050 uses) and the design-system steps the
`Text` atom uses (`caption`, `body`, `lead`, `heading`…). Sizes from the
first can't follow a theme or a redesign, and the two drifted (18px,
24px and 30px headings sat next to 20px, 22px and 28px ones).

**What:** the design-system scale is now the only text-size scale. Its
values live in one token file, Tailwind's own sizes are reset so
`text-sm` renders nothing, and a lint rule fails any raw or arbitrary
size at error with a baseline of 0.

**How:**
- **Tokens, T3 Code style.** `src/app/tokens/typography.css` holds one
`:root` block with every step's size and line height. `globals.css`'s
`@theme inline reference` maps each step to its `text-<step>` utility
(`--text-body: var(--text-body)`), and `--text-*: initial` drops
Tailwind's defaults. A redesign or a theme changes values in that one
file. This is a separate file from PR A's `tokens/theme.css`, so the two
PRs don't collide.
- **Reviewed mapping + codemod.** `scripts/ds/text-size-scale.ts` holds
the scale and the mapping table below. It also has a codemod that
rewrites class names inside string literals and template text only (via
the TypeScript AST, so comments are untouched). It keeps variant
prefixes and `!`. It rewrote 1,049 classes in 390 files.
- **Two new steps.** I added two steps so no size moves more than 4px
(except one emoji):
- `heading-md`, 24/32: Tailwind's `2xl`. Snapping it to `heading-sm`
22/24 would have cut every such line box by 8px.
  - `hero`, 64/1: the tour hero. The scale topped out at 44px.
- **Elements kept as they are.** Converting ~1,000 raw `<p>`/`<span>`s
to `<Text>` would also force `font-sans`, `font-normal` and a default
colour onto copy that today inherits them from its parent. That is a
pixel risk unrelated to size. So the codemod swaps the size class in
place, and the Text variants that were named after Tailwind's scale were
renamed or merged (below).

| Tailwind | Step | Size / line height | Change |
|---|---|---|---|
| `text-xs` | `text-caption` | 12/16 → 12/18 | line +2px |
| `text-sm` | `text-body` | 14/20 → 14/22 | line +2px |
| `text-base` | `text-body-lg` | 16/24 → 16/26 | line +2px |
| `text-lg` | `text-lead` | 18/28 → 20/28 | size +2px |
| `text-xl` | `text-lead` | 20/28 | none |
| `text-2xl` | `text-heading-md` (new) | 24/32 | none |
| `text-3xl` | `text-heading` | 30/36 → 28/40 | size −2px, line +4px |
| `text-4xl` | `text-title` | 36/40 → 32/40 | size −4px |
| `text-5xl`…`9xl` | `text-display` / `text-hero` (new) | | only `7xl`
was used: 72 → 64px |

Text with an explicit `leading-*` keeps it, so its line height does not
move.

### Changes 🏗️

**Scale and tokens**
- New `src/app/tokens/typography.css`: 14 steps, tiny (10px) to hero
(64px).
- `globals.css`:
  - `@import`s the token file;
  - maps the steps in `@theme`;
  - resets Tailwind's `--text-*`.
- `tw-merge.ts` knows `heading-md` and `hero`.

**Call sites:** 1,049 raw sizes → scale classes (390 files, stories and
tests included), plus these hand edits:
- **Text variants named after Tailwind's scale:**
- `lg-semibold` and `lg-medium` were now identical to `lead-semibold`
and `lead-medium`, so they were merged into them.
- `2xl`, `2xl-bold` and `2xl-bold-tracking-tight` were renamed to
`heading-md`, `heading-md-bold` and `heading-md-bold-tracking-tight`.
- **New `hero` Text variant:** `TourHero` uses it instead of a
suppressed `text-[4rem] leading-none` restyle.
- **`FileIllustration`:** the 5px/6px code glyphs (`{`, `:`, `</`) in
the file thumbnails are artwork far below the scale's 10px floor. They
are now drawn as SVG text in the exact box the text took (measured:
advance width × 1.5em, baseline at 1.1em), so the odd sizes are gone
without changing the thumbnails.
- **Dialog title:** dropped `text-md`, a class that never existed and
generated nothing.
- **Resolved TODOs:** removed the `TODO(tokens): SECRT-3152` comments
this PR settles (7 files).

**Streamdown:** the chat's markdown renderer, a dependency, writes
Tailwind's size names in its own markup (headings, table cells, inline
code, code-block header, download menus).
- `globals.css` gives those classes the same steps inside
`:where([data-streamdown], [data-streamdown] *)`.
- It weighs one class and sits before the utilities, so sizes the app
passes in still win, as before.

**Lint**
- New `design-system/no-raw-text-size`, at `error` for `src/**` (stories
and tests included, since a raw size renders nothing there either). It
is off only for `__generated__`.
- It reports named Tailwind sizes with any variant prefix, and arbitrary
lengths (`text-[13px]`, `text-[length:..]`, `text-[calc(..)]`). The
message names the step to use.
- The baseline has 0 entries for it.

**Tests and docs**
- `scripts/__tests__/type-scale.test.ts` compiles the real stylesheet
and checks:
  - every step's utility reads its token;
  - the token file holds exactly the scale;
  - Tailwind's sizes generate nothing;
  - the Streamdown shim uses the same steps.
- Rule tests in `design-system.test.ts`. `tailwind3-compat` and
`tw-merge` tests are updated to the new names.
- `docs/engineering/design-system.md`, Typography: the scale, the
mapping and the rule. The token stories show the new scale.

**Counts**

| Measure | Before | After |
|---|---|---|
| Type scales | 2 | 1 |
| Odd/arbitrary sizes | 10 distinct (5, 6, 10–16px, 4rem) | 0 |
| Raw `text-<size>` uses in code | ~1,050 | 0 (lint error, baseline 0) |

**Size:** +1,753 / −1,235 lines in 399 files, about 3,000 changed lines.
It stays one PR as agreed; the bulk is one-token class swaps from the
codemod.

### Visual changes

**Method**
- Playwright against `pnpm dev` of `dev` (441dc97) and of this branch,
on the same seeded backend and admin test user. The seed data:
  - 17 marketplace workflows;
  - 2 library workflows and a folder;
  - 5 uploaded files.
- Viewports 1440x900 and 390x844, full page, animations frozen. 36
pages, 72 pairs, pixel-diffed.
- Each pair was also compared as computed `font-size`/`line-height` per
visible text element. That comparison is what the list below is built
from.
- Pages:
  - login, signup, reset password, tour;
  - home (Otto), build, team, autopilot;
  - library, library workflow;
  - files (list, and grid with the thumbnail illustrations forced on);
  - marketplace (home, search, workflow, creator, skills);
- settings (profile, account, billing, integrations, API keys, memory,
creator dashboard), profile dashboard;
  - 11 admin pages.

**No visible change (34 pairs):**
- login, signup, reset password and tour: the hero moved from
`text-[4rem] leading-none` to `hero`, which is the same 64/64px. 29
anti-aliased pixels differ.
- home/Otto, build, library workflow, team, autopilot.
- every settings page and the profile dashboard.
- admin Users and Rate limits.

A few of these differ by under 50 anti-aliased pixels. Settings ›
Integrations (390) differs only because a provider logo loaded later in
one run.

**Small changes** (every text change is ≤4px; most are a 2px taller line
box):
- **Everywhere `text-sm`/`text-xs`/`text-base` was used:**
- line height +2px, size unchanged: 14/20 → 14/22, 12/16 → 12/18, 16/24
→ 16/26;
  - a column of such lines grows by 2px per line;
  - text with its own `leading-*` is unchanged.
- **Marketplace home:**
  - "Workflows" heading 30/36 → 28/40px;
  - section subtitles 18 → 20px;
  - card text +2px line;
  - at 390 the hero "Hire an AI expert for your team" goes 30 → 28px.
  - Page +6px (1440) / +2px (390).
- **Marketplace search, creator and workflow pages:** workflow card
titles 18 → 20px (same 28px line). "No runs" / run counts get a +2px
line.
- **Marketplace skills:**
  - "Skills" heading 30/36 → 28/40px;
  - subtitle 16/24 → 16/26;
  - "Go to your skills" link 14/20 → 14/22.
  - Page +8px at 390.
- **Library:**
  - filter/sort labels 14/20 → 14/22;
  - "Idle" status chips 12/16 → 12/18.
  - Page +2px (1440) / +3px (390).
- **Files:**
  - "All / Uploaded / Generated" chips 14/20 → 14/22.
- Grid thumbnails unchanged: the new SVG glyphs measure 3.00/6.00 ×
5/7.5px against the text's old 3.02/6.02 × 5/7.5px.
- **Admin pages:**
- page titles 30/36 → 28/40px: Block cost estimates, Bots, Diagnostics,
Execution analytics, Marketplace, Memory, Platform costs, Spending;
  - labels and body text +2px line; captions +2px line;
  - Diagnostics' stat numbers 30 → 28px;
  - Execution analytics' "Advanced options" 18 → 20px;
  - Platform costs' "(unsaved)" 10/16 → 10/18.
  - Dense pages grow from the added line height:
- Diagnostics +48px (1440) / +74 to 88px (390, depending on how many
items it lists);
    - Platform costs +34 / +48px;
    - Bots +16 / +24px;
    - Memory +8 / +26px.
- Admin › Marketplace also grows +24px, but from data: a "1 hour ago"
cell read "2 hours ago" and wrapped. Only its title changed from code.
- **Not on the pages above (from the mapping):**
- every remaining `text-lg` title (dialogs, sheets, cards) goes 18 →
20px on the same 28px line;
  - `text-3xl` headings 30/36 → 28/40;
- chat markdown inside Streamdown follows the same steps: h1 30 → 28px,
h4 18 → 20px, code/table/menus +2px line.

**Bigger visual changes**
- **Library (folders):** the folder card's emoji goes from 72px to 64px
(`text-7xl` → `hero`, the scale's largest step). The icon is drawn
scaled down, so on screen it goes from about 31×48px to 27×46px. It is
the only text-size change above 4px. Screenshots are in the PR comment.
- **Marketplace workflow page, 1440:** the "Build AI workflows and share
your vision" banner heading goes 36 → 32px. The size change itself is
within 4px, but the heading now fits on one line instead of two, so
everything below it moves up 40px. At 390 it goes 30 → 28px and still
wraps the same way. Screenshots are in the PR comment.


### Agents and large language models used

- Claude Code with Claude Opus 5.5 (claude-opus-5-5).

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm lint` (design-system rules at error), `pnpm types`, `pnpm
format`
- [x] `pnpm test:unit`: 1,177 files, 13,024 tests passed. Locally vitest
also reported 3 unhandled `ECANCELED`/`ECONNREFUSED` socket errors from
the desktop-preview iframe tests (`chat-surface-parity`,
`computerFace`). The same errors happen on untouched `dev` in this
sandbox, which has no outbound network for those iframes.
- [x] `type-scale`, `design-system`, `tailwind3-compat`, `tw-merge`
script/unit tests
- [x] Before/after Playwright screenshots, 36 pages × 1440x900 and
390x844, pixel-diffed (see above)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; no
baseline count was raised (the new rule's baseline is 0)
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours added; no colour tokens renamed or removed (PR A's job)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone` where it did;
the size swaps keep each element as it was (see How)
- [x] Before/after screenshots at 1440 and 390 px were taken and diffed;
every visible change is listed above
- [x] User-facing copy unchanged

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): none

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- conductor-workspace-link -->

---

[Open workspace in
Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/210)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-11-03 12:28:21 +00:00
Abhimanyu YadavandClaude Opus 5.5 2ce23c2b44 refactor(frontend): Text, Icon and Button variants instead of call-site restyles (#16513)
### Why / What / How

**Why:** SECRT-3097 follow-up, PR B of the theming plan. Text, Icon and
Button were restyled at the call site about 1,700 times (colour, weight,
padding, radius and motion classes in `className`). Every one of those
is a look the primitive doesn't know about, so it can't follow a theme,
and copies of the same look drift apart.

**What:** the looks those call sites were reaching for are now props on
the atoms. The call sites use the props, and `className` is left for
layout. `design-system/no-restyle` now also checks Text, Icon and Button
inside `src/components/{atoms,molecules,organisms}`, where it used to be
off. The baseline is whatever is left.

**How:** the props are cva variants that use role tokens only, and the
colour values are shared by all three atoms
(`src/components/styles/tones.ts`). Call sites were moved in two ways:
- **A codemod** (not committed) that rewrote a site only when the merged
class set (twMerge, with alias colours resolved to their role and
size/line-height compared as computed values) came out identical.
- **Hand edits** for the rest, under one rule: the same rendering, or a
change of at most 4px or a slight shade shift. Every such change is
listed below.

No colour tokens were renamed or removed (PR A's job), and the type
scale is untouched (PR D's job).

### Changes 🏗️

**New props**
- **Text:** `weight`, `mono`, `tabular`, `italic`, `decoration`,
`transform`, `tracking` (token steps only), `align` and
`motion="colors"`. The existing `tone` gains `base`, `emphasis-soft`,
`inverse`, `brand`, `brand-strong`, `accent`, `error-strong`,
`warning-bright`, `info` and `info-strong`.
- **Icon:** `tone` (same values as Text), `spin`, and `motion`
(`transform` | `colors` | `opacity`). Icon only cn-merges `className`
when one of these props is set, so existing class strings pass through
untouched.
- **Button:**
- variants `ghost-muted`, `ghost-subtle`, `ghost-danger`,
`ghost-warning`, `brand`, `warning`, `success` (the "copied" state) and
`tray` (the chat composer's icon buttons);
- sizes `row` (32px), `inline` / `inline-xs` (no box, for a link inside
copy) and `icon-2xs` (24px);
- a `shape` prop (`pill` / `rounded` / `soft`) and a `tone` prop for
text colour;
  - `compact-ghost` gains an `aria-pressed` fill.
- Stories (Text `Modifiers`; new Icon stories; Button `QuietVariants`,
`RowAndInlineSizes`, `Shapes`) and unit tests for every new prop.
`docs/engineering/design-system.md` is updated.

**Call sites:** 380 files. Besides the prop swaps:
- Box styling that sat on a `Text` (padding, fill, border) moved to a
wrapping element.
- Buttons that only fade in on row hover moved the reveal to a wrapping
`flex` span. `focus-visible` became `has-[:focus-visible]`, which
behaves the same.
- `DateTimeInput`'s trigger is now a plain `<button>` with the exact
classes it rendered before, like `DateInput`'s.
- `MessageBranchPage` no longer uses `ButtonGroupText`, which had no
other callers and was removed.

**Lint**
- `.oxlintrc.json` puts the Text/Icon/Button contracts into the
atoms/molecules/organisms override and lets every other component
through there.
- The Text contract also accepts `gap-*` and `text-ellipsis` (layout and
truncation).
- The baseline was regenerated:
  - New entries are only those 20 newly-checked component files.
- The emerald-300 raw colour moved with the tones from `Text/helpers.ts`
to `styles/tones.ts`.
  - No other count went up.

**Restyle counts:** Text/Icon/Button classes reported by `no-restyle`,
on the same tightened scope, counting suppressed ones too.

| | Before | After |
|---|---|---|
| Text | 585 | 80 |
| Icon | 515 | 43 |
| Button | 592 | 182 |
| **Total class uses** | **1,692** | **305** |
| Call sites | ~880 | ~150 |

The plan's target was under 150. The call-site count meets it; the
class-use count does not. What's left is mostly bespoke one-offs:
- split buttons;
- segmented controls;
- gradient text;
- responsive heading sizes;
- line-height overrides with no variant (PR D will fold those into the
type scale);
- group-hover colours whose state React doesn't know;
- raw palette colours (PR A).

**Size:** about 5,800 changed lines across 393 files, over the ~3,000
the plan expected. It stays one PR as agreed; the bulk is one-token prop
swaps.

### Visual changes

**Method**
- Playwright, against production builds of `dev` (e681d74) and this
branch, on the same seeded backend and test user.
- Viewports 1440x900 and 390x844, full page, 33 pages, 66 pairs,
pixel-diffed.
- Pages: login, signup, home, copilot, library, library agent,
followups, skills, build (empty and with a workflow), marketplace (home,
search, skills, workflow, creator), team, autopilot, artifacts, raise,
all 11 settings pages, tour, tour chat, reset password and setup wizard.

**Result: no code-caused pixel change on any of the 66 pairs.** 58 are
identical. 8 differ only by data or animation:
- tied marketplace cards in a different order;
- "1h ago" vs "2h ago";
- an animated avatar frame;
- texture noise on raise.

The changes below are in components those pages don't render at rest:
dialogs, builder nodes, hover and focus states. Each is within the
owner's "small" rule.

**Small changes** (text shifts ≤4px, slight shade shifts, hover- or
motion-only)
- **Build:**
- Draft recovery "Discard" goes from 34px to 36px, the same as
"Restore".
  - Node output copy buttons: 30px → 32px. "View data": 30/34px → 32px.
  - Data viewer copy/download buttons: 26px → 28px.
  - Filter sheet close and edge remove (X): 22px → 24px.
  - Filter sheet "Clear"/"Apply": side padding 8px → 12px.
  - Text input expander copy: 34px → 32px.
- Simulate and "Update Anyway": the hover border is amber instead of
grey/dark.
  - Copy buttons: hover text stays foreground-base instead of strong.
- **Copilot:**
  - "Hire and start": side padding 14px → 12px.
  - Connection chip: side padding 10px → 12px.
  - Tier glyph: hover fill surface-hover → surface-muted.
- File download/delete, integrations header icons, "View usage" and
"Show all items": hover text now darkens to foreground-body.
  - "Upgrade to Max" fill: brand-hover #6C2EDF → brand #7733F5.
- **Raise:** "Back"/"Start over" go from 34x36 to 36x36, and their hover
fill from surface-hover to surface-muted.
- **Home:** tile filter gap 4px → 6px, side padding 10px → 12px, hover
fill surface-hover → surface-muted.
- **Paywall:**
  - The inactive Monthly/Yearly segment's text darkens on hover.
  - "Log out" drops a 90% white fill that sat on the white dialog.
- **OAuth authorize and integrations setup wizard:** the two button
labels go from 18px/28px to 15px/24px (`textSize="body-md"`). Button
height is unchanged.
- **Login/signup:** gap between the Google logo and its label 12px →
8px.
- **Library:**
  - Move-to-folder rows: left padding 12px → 16px, gap 12px → 8px.
  - "Simulate" (run modal): gap 6px → 8px.
- **Marketplace skills:** the install/actions dropdown arrow's side
padding goes from 10px → 12px.
- **Settings/memory and team notes:** "Forget" side padding 8px → 12px.
- **Components:**
  - File-tree collapse button: 24x32 → 32x32.
  - Changelog dismiss: hover text goes from base to body.
- Wallet usage "Retry": height 26px → 28px, padding 8px → 10px, min
width 88px → 80px.
- Pending reviews "Reject": resting fill red-600 → red-500, the
destructive variant's own fill.
  - Ayrshare connect: gap 8px → 6px.
- Cron scheduler "Select all"/"Weekdays"/"Weekends": side padding 8px →
12px.
  - Plan offer link: the loading-spinner gap goes from 6px → 4px.
- Activity search toggle and two builder toggles: only the focus-ring
corner radius changes.
- Builder filter chip: only if the pointer is already over a selected
chip when it mounts, the label shows white until the mouse moves.
- **Motion only:**
  - Spinners using `spin` stop for users with reduced motion turned on.
- Icon transitions using `motion` run at 200ms; some were 150ms or
300ms.

**Bigger visual changes:** none.

### Agents and large language models used

- Claude Code with Claude Opus 5.5 (claude-opus-5-5), plus Claude Code
subagents on the same model for the per-directory call-site passes.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pnpm lint` (design-system rules at error), `pnpm types`, `pnpm
format`
  - [x] `pnpm vitest run`: 1,174 files, 12,987 tests passed
  - [x] Unit tests for every new Text, Icon and Button prop
- [x] Before/after Playwright screenshots, 33 pages x 2 viewports,
pixel-diffed (see above)

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules. No
baseline count went up except:
  - the files newly checked by the tightened `no-restyle`;
- `styles/tones.ts`, which took over the emerald-300 entry from
`Text/helpers.ts`.
- [x] No `dark:`/`light:` classes, raw palette classes or hex/`rgb()`
colours added. The new variants use role tokens.
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`
- [x] Before/after screenshots at 1440 and 390 px. No code-caused pixel
change on the audited pages; the method is described above.
- [x] User-facing copy unchanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- conductor-workspace-link -->

---

[Open workspace in
Conductor](https://app.conductor.build/pro-user-8d964eae-256d-46a9-b469-fd3c36fa6673/w/209)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 04:17:46 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 9e2a2c2846 feat(backend): remove Langfuse tracing of AutoPilot conversations (#16026)
### Why / What / How

**Why:** AI observability moved to PostHog. Since the 23 Oct release,
PostHog shows each AutoPilot reply as one trace with its tool steps
(#15604, #15605) and every model call (OpenRouter Broadcast), so
Langfuse tracing is a second copy of every conversation at another
vendor. Product (Toran, 22 Oct) approved removing Langfuse entirely;
this PR removes the tracing only. Prompt management stays in Langfuse
until [SECRT-3022](https://linear.app/autogpt/issue/SECRT-3022) moves
the prompts to PostHog.

**What:** No path sends anything to Langfuse except the two prompt
fetches. Ticket:
[SECRT-2711](https://linear.app/autogpt/issue/SECRT-2711).

**How:** The tracing code is deleted rather than switched off. The
prompt fetches go through one Langfuse client built with
`tracing_enabled=False` (`backend/util/langfuse_prompts.py`), and
nothing else in the backend builds a Langfuse client or calls
`langfuse.get_client()`, so the SDK never sets up a tracer or span
exporter, whatever `LANGFUSE_TRACING_ENABLED` says.

### Changes 🏗️

Removed (tracing):
- Baseline path: the `langfuse.openai` wrapper (main and aux clients are
plain `openai.AsyncOpenAI` again), `propagate_attributes` and the OTel
cost attributes on the turn span.
- SDK path: `_setup_langfuse_otel` (LangSmith's Claude Agent SDK hook →
OTLP → Langfuse), the Langfuse parent span per turn, and the
`get_user_tier` lookup that only fed its metadata.
- `copilot/sdk/trace_export.py` and its
`opentelemetry_otlp_credential_provider` plugin entry in
`pyproject.toml`; the `langsmith` dependency (lock drops `langsmith`,
`uuid-utils`, `xxhash`).
- `copilot/inference/trace.py`, the Langfuse trace around background LLM
calls (briefing, dream, learning review, consult, style eval). The calls
and their cost records are unchanged; `InferenceContext.trace_id` and
the `langfuse_trace_id` cost-log metadata go with it.
- The `openrouter-cost-reconcile` event written onto the Langfuse trace
(the cost record itself is unchanged).
- Ratings: no Langfuse score any more. Ratings are still saved in
Postgres and sent to PostHog as `$ai_metric`, unchanged.
`MessageFeedbackResponse.langfuse_target` is removed (the frontend never
read it; openapi.json regenerated).
- `scripts/replay_session_trace.py`, which replayed Langfuse traces.
- The `langfuse_tracing_environment` setting.

`ChatMessage.langfuseTraceId` and `ChatMessageFeedback.langfuseTraceId`:
nothing reads them now, so they are no longer written. The columns stay;
SECRT-3022 can drop them with the rest of Langfuse.

Google data redaction (#15490): the Langfuse `mask` and the LangSmith
`hide_inputs`/`hide_outputs` hooks are removed with the tracing. The
rest of `copilot/google_data_redaction.py` stays, because other code
still calls it: the Google call/output checks and remembering
(`tools/base.py`, `gate/held.py`, `sdk/tool_adapter.py`),
`GOOGLE_DATA_METADATA_KEY` (`tools/workspace_files.py`),
`holds_google_data` and `tracing_google_data()`. Frontend replay masking
is untouched.

Kept as is:
- Prompt fetching: `_fetch_langfuse_prompt` with the bounded-stale cache
(`langfuse_prompt_name`, `langfuse_prompt_cache_ttl`) and the onboarding
greeting prompt (`BRAIN_DUMP_GREETING_PROMPT_NAME`).
- Our PostHog traces and spans (`ai_spans.py`, #15604/#15605) and the
OpenRouter Broadcast attribution (`llm_attribution.py`,
`x-session-id`/`x-user-id` headers).

Docs: `.env.default` says the Langfuse keys are for prompts only;
`docs/engineering/tracking-plan.md` and code comments no longer mention
a Langfuse score or trace.

Config:
- Still used: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`,
`LANGFUSE_HOST` (prompt fetches only).
- No longer used, safe to remove from deployment secrets/config later:
`LANGFUSE_TRACING_ENVIRONMENT`, and any `LANGSMITH_*` or `OTEL_*`
variables (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`,
`OTEL_RESOURCE_ATTRIBUTES`,
`OTEL_PYTHON_EXPORTER_OTLP_HTTP_TRACES_CREDENTIAL_PROVIDER`) if they
were set by hand. The code used to set those at startup itself.

Outside this repo, not done here:
- Turn off the Langfuse destination in OpenRouter's Broadcast settings,
if it is on (PostHog stays).
- Decide whether to delete the traces already in Langfuse or set a
retention period.

### Agents and large language models used

- Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] New `backend/util/langfuse_prompts_test.py`: the prompt client is
built from settings with tracing off, sets up no tracer even with
`LANGFUSE_TRACING_ENABLED=true`, and is built once
- [x] Unit tests for changed modules pass locally (feedback routes and
service, prompt cache, onboarding greeting, Google data redaction,
inference record/context, briefing, dream orchestrator, consult,
OpenRouter cost, SDK service helpers and retry scenarios, db/model
stamps); service-backed tests left to CI
- [x] ruff/isort/black and pyright on changed files; frontend `pnpm
types`, prettier and the `AssistantMessageActions` test
- [ ] After deploy to dev: AutoPilot replies still use the Langfuse
system prompt, and no new traces appear in Langfuse

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 16:24:19 +00:00
Krzysztof CzerwinskiandClaude Opus 5.5 3c06724a3e refactor(platform): remove LaunchDarkly and read every flag from PostHog (#15853)
### Why / What / How

**Why:** LaunchDarkly gets users' email, email domain, role and signup
date, without consent. Dev and prod have served every flag from PostHog
since 16 Oct (infra #437/#439), and #15583 made PostHog the code
default, so LaunchDarkly no longer answers any flag. Removing it closes
gap 3 of the data-flow audit and drops a vendor. The team decided on 23
Sep to remove it entirely, experiments included. Ticket:
[SECRT-2708](https://linear.app/autogpt/issue/SECRT-2708).

**What:** Both LaunchDarkly SDKs go, along with the `launchdarkly` and
`dual` flag backends and the switch between them, on the backend and the
frontend. Flags behave exactly as they do today: the backend asks
PostHog with the same person properties, and the browser reads
`/api/feature-flags` as it already does everywhere.

**How:** The backend's LaunchDarkly `Context` was also the user context
PostHog's person properties were built from. It is now a small dataclass
with the same fields, cache and degraded-lookup rules. The backend sends
the same properties: `role`, `email`, `email_domain`, `created_at`, the
per-request `country`, `environment` and `is_anonymous`.

### Changes 🏗️

**Backend**
- Removed the `launchdarkly-server-sdk` dependency; `poetry.lock`
regenerated with Poetry 2.5.1. The `launchdarkly` extra on `sentry-sdk`
was already gone (#14805).
- `backend/util/feature_flag`:
  - Every read goes to PostHog.
- Removed: LaunchDarkly init/shutdown, `get_client`, the dual shadow
reads and mismatch log, and `mock_flag_variation`.
- Settings: removed `FeatureFlagBackend`, `feature_flag_backend` and
`launch_darkly_sdk_key`.
- Infra #452 (merged 23 Oct) already removed `LAUNCH_DARKLY_SDK_KEY` and
the flag-backend setting from prod. If any environment still sets either
variable, `Settings` allows extra keys, so it is ignored and boot is
unaffected. A test covers this.
- Deleted `scripts/feature_flag_sync.py` and
`scripts/sync_feature_flags_to_posthog.py` with their tests. They only
copied LaunchDarkly flags into PostHog.
- `country` (#14799) already reaches PostHog as the person property
`country`, per read and never stored.
- It stays `country`, not `$geoip_country_code`: server-side reads get
no GeoIP, and the trial-offer flag in PostHog targets `country`.
- An anonymous stand-in context (a failed user lookup) still sends no
country, as before.
- `.env.default`: removed `LAUNCH_DARKLY_SDK_KEY` and
`FEATURE_FLAG_BACKEND`.
- LaunchDarkly wording in comments and docstrings now says PostHog or
feature flag.
- Two messages Otto reads now say "feature flag" instead of
"LaunchDarkly flag".
  - Three OpenAPI descriptions changed; `openapi.json` was regenerated.

**Frontend**
- Removed `launchdarkly-react-client-sdk` (lockfile regenerated),
`LDProvider` and the LaunchDarkly context building, including the device
context from #14288.
- Removed the `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` switch with its
`launchdarkly`, `dual` and client-side `posthog` paths. Every
environment runs `server`, so `useFlagSource` now always reads
`/api/feature-flags`.
- Removed `environment.areFeatureFlagsEnabled` /
`getLaunchDarklyClientId`, and the `setPersonPropertiesForFlags` call,
which only ran in the client-side `posthog`/`dual` modes.
- Retired the `feature_flag_mismatched` event, which only the dual
backend sent.
- Experiments: `useExperiment.ts` and `useReportAssignment.ts` stay on
PostHog (the LaunchDarkly experiment bridge was already deleted). The
browser's assignment `source` is now only `"posthog"`.
- `.env.default`: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`,
`NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID` and
`NEXT_PUBLIC_FEATURE_FLAG_BACKEND`.
- Tests for the LaunchDarkly, dual and client-side PostHog paths are
removed. Tests for the remaining behaviour now mock the flag seam
instead of the LaunchDarkly SDK.

**Docs and image**
- Updated `docs/engineering/feature-flags.md`, `activation-metrics.md`,
`tracking-plan.md`, `docs/platform/contributing/managing-llm-models.md`,
`frontend/CONTRIBUTING.md` and the threat model.
- Single-container image: removed `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED` and
the step that deleted `ldclient`'s bundled test certificates.

**Kept on purpose**
- The backend `AssignmentSource` still accepts `"launchdarkly"`, so
existing `experiment_assignment` rows recorded with it stay valid.
- The stored `routingSource` value `"ld"` is unchanged, because it is a
stored value.

**Config changes**
- Removed env vars: `LAUNCH_DARKLY_SDK_KEY`, `FEATURE_FLAG_BACKEND`
(backend); `NEXT_PUBLIC_LAUNCHDARKLY_ENABLED`,
`NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID`, `NEXT_PUBLIC_FEATURE_FLAG_BACKEND`
(frontend).
- Leftover values are ignored. Infra #452 already removed the
LaunchDarkly key and flag-backend setting from prod. Any remaining
`FEATURE_FLAG_BACKEND` or `NEXT_PUBLIC_FEATURE_FLAG_BACKEND` values in
infra or Vercel can be dropped whenever convenient.

**Rollback:** revert this PR and [infra
#452](https://github.com/Significant-Gravitas/AutoGPT_cloud_infrastructure/pull/452),
so `LAUNCH_DARKLY_SDK_KEY` and the Vercel LaunchDarkly vars come back.
Then set `FEATURE_FLAG_BACKEND=launchdarkly` (backend) and
`NEXT_PUBLIC_FEATURE_FLAG_BACKEND=launchdarkly` (frontend, needs a
rebuild). LaunchDarkly's flags must still match PostHog's, because
LaunchDarkly hasn't served since 16 Oct. Reverting this PR alone keeps
PostHog serving, since it is already the code default.

### Agents and large language models used

- Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Backend flag unit tests (`backend/util/feature_flag/`,
`api/features/feature_flags/routes_test.py`,
`data/subscription_trial_config_test.py`, `util/metrics_test.py`) pass
locally, including real PostHog clients evaluating the country rule, the
employee cohort and the visitor split in-process
- [x] Frontend `pnpm lint`, `pnpm types` and `pnpm test:unit` pass
(flag, experiment, PostHog provider and affected page tests)
- [x] `openapi.json` regenerated; `poetry.lock` and `pnpm-lock.yaml`
regenerated
  - [ ] CI green
- [ ] After merge and deploy: flags on dev and prod still match today's
(e.g. `/api/feature-flags` for an employee and for a signed-out visitor)

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 14:59:19 +00:00
Bently b9033c3f25 feat(backend): add Gemini 3.5 Flash, 3.5 Flash Lite and 3.1 Flash Lite to the LLM catalog (#15087)
### Why / What / How

**Why:** Google has announced Gemini 4 Argon, and we want the platform
ready for the Gemini lineup. Our catalog was missing three current
Gemini models that OpenRouter already serves. Blocks couldn't select
them, because an id without an `LLMModel` value isn't valid.

**What:** This adds Gemini 3.5 Flash, Gemini 3.5 Flash Lite and Gemini
3.1 Flash Lite (the non-preview release) as `open_router` models. Each
one gets a catalog entry, an `LLMModel` enum line and a pricing test,
the same pattern as #14943, #14697 and #14422.

**How:** Context window, max output and prices are OpenRouter's live
values from `GET /api/v1/models` on 2026-09-30. Credit rates are USD per
1M × 150, the same conversion as `_USD_PER_1M_DIVISOR`. OpenRouter
models are charged from the `x-total-cost` header, so the per-1M rates
here only drive the builder's "$X in / $Y out" label.

| Model id | Context | Max output | $ in / out per 1M | Cached input |
Credits in / out per 1M | Credits per run | Price tier |
|---|---|---|---|---|---|---|---|
| `google/gemini-3.5-flash` | 1,048,576 | 65,536 | 1.50 / 9.00 | 0.15 |
225 / 1350 | 4 | 2 |
| `google/gemini-3.5-flash-lite` | 1,048,576 | 65,536 | 0.30 / 2.50 |
0.03 | 45 / 375 | 1 | 1 |
| `google/gemini-3.1-flash-lite` | 1,048,576 | 65,536 | 0.25 / 1.50 |
0.025 | 37.5 / 225 | 1 | 1 |

All three set `supports_tools`, `supports_json_output` and
`supports_reasoning`, because OpenRouter lists `tools`,
`response_format` and `reasoning` for each of them.

**Not added:**
- **Gemini 4 Argon.** It isn't on OpenRouter yet. It's missing from
`/api/v1/models` and from `?q=argon`, and every plausible id
(`google/gemini-4-argon`, `google/gemini-argon`, `google/gemini-4`, and
preview variants) returns 404 on `/endpoints`. A catalog entry will
follow when OpenRouter lists it.
- **Gemini 3.6 Flash and 3.7 Flash.** Gemini 3.8 Flash replaces both at
the same price.
- **Other variants.** The `:batch` variants, the
`~google/gemini-*-latest` aliases, `3.1-pro-preview-customtools`, and
the image models (Nano Banana).

**Deprecation warning** (found while comparing against OpenRouter; this
PR doesn't change it):
- `google/gemini-2.0-flash-001` and `google/gemini-2.0-flash-lite-001`
have no OpenRouter endpoints left. `scripts/check_openrouter_prices.py`
reports both as `NO_ENDPOINTS`.
- OpenRouter shows `expiration_date: 2026-10-20` for
`google/gemini-2.5-flash`, `google/gemini-2.5-flash-lite` and
`google/gemini-2.5-pro`.
- `google/gemini-2.5-flash-lite` is the default model for the simulator
(`executor/simulator.py`) and for AutoPilot's simulation mode
(`copilot/config.py`). Both need a new default before 2026-10-20.

**Related:** #14386 adds a separate route, a direct Google Gemini
provider. This PR doesn't overlap with it or change it.

### Changes 🏗️

- `backend/data/llm_registry/catalog.py`: new catalog entries for
`google/gemini-3.5-flash`, `google/gemini-3.5-flash-lite` and
`google/gemini-3.1-flash-lite`.
- `backend/data/llm_registry/llm_models.py`: `GEMINI_3_1_FLASH_LITE`,
`GEMINI_3_5_FLASH` and `GEMINI_3_5_FLASH_LITE` enum values.
- `backend/data/llm_registry/catalog_test.py`: a pricing test per model
that checks run credits, per-1M rates, max output, context window and
price tier.
- `docs/integrations/block-integrations/llm.md`: the new ids added to
the model lists in enum order.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Before, on `dev` (d2a51bfd27) on a tester VM: all three ids fail
with `'google/gemini-3.5-flash' is not a valid LLMModel` (same for the
other two).
- [x] After, on this branch: all three ids resolve to
`provider=open_router`, with the context window, max output, credits per
run and per-1M rates in the table above.
- [x] `poetry run test backend/data/llm_registry/
backend/blocks/test/test_llm.py backend/data/block_cost_config_test.py`:
203 passed.
- [x] `black --check`, `isort --check-only` and `pyright` on the changed
Python files: clean.
- [x] Deployed this branch on a tester VM. Ran one AI Text Generator
graph per model on the platform's OpenRouter credential, with 3.8 Flash
as a control. All four runs finished COMPLETED and replied "pong".
- [x] Each run wrote a `PlatformCostLog` row with `provider=open_router`
and `trackingType=cost_usd`, and each cost matches tokens × OpenRouter
list price exactly:

    | Model | Input tokens | Output tokens | Recorded cost |
    |---|---|---|---|
    | 3.5 Flash | 7 | 71 | $0.0006495 |
    | 3.5 Flash Lite | 7 | 1 | $0.0000046 |
    | 3.1 Flash Lite | 7 | 1 | $0.00000325 |
    | 3.8 Flash (control) | 8 | 73 | $0.00027975 |

- [x] `scripts/check_openrouter_prices.py` after the change: all 10
Gemini entries MATCH OpenRouter's live prices.
- [ ] Charging credits to a user's balance was not tested: credits are
turned off on the tester VM, so that step does nothing there, for the
3.8 Flash control too. The charge goes through the same `x-total-cost`
path that 3.8 Flash already uses.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
2026-11-01 15:53:36 +00:00
Reinier van der Leer 6c85f3b1ee Merge branch 'master' into dev 2026-11-01 14:03:50 +01:00