Skip to content

Release/1.21.0 - #1097

Merged
philmerrell merged 2501 commits into
mainfrom
release/1.21.0
Sep 14, 2026
Merged

philmerrell merged 2501 commits into
mainfrom
release/1.21.0

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Release 1.21.0

58 PRs since the 1.20.0 cut. Full detail in RELEASE_NOTES.md and CHANGELOG.md.

Customize (/customize/{tools,skills,connectors}) replaces the composer's settings drawer, which had been presenting durable, account-wide state as "settings for this conversation." Tools and skills gain detail pages, an MCP server's sub-tools can be switched one at a time, and /skill-name in the composer invokes a skill for a single message the way @agent already did.

Chat stops guessing about itself. A four-tool answer renders as one card instead of five; the loading indicator states what the agent is actually doing (Running browse_web · 4s) from a new agent_status event; each finished tool batch gets a model-written summary from a Nova Micro side-channel that never touches the conversation or the cacheable prefix.

Three cost reductions on the same request path. The tool catalog moves off a full-table Scan onto EntityTypeIndex (95 items read to return 24, before); four tenant-global catalogs gain a TTL + single-flight cache; the per-request user-profile upsert is throttled, removing ~24 DynamoDB writes from every SPA first load.


⚠️ Decision required before merge

This release removes the only way to select a Conversation Mode. The settings drawer is deleted (#1079) and the picker meant to replace its Mode control was parked before release (#1088) pending feedback on its placement.

Prod carries one enabled mode — Guided Learning, a Socratic tutoring prompt — and use is accelerating: 1 session in July, 20 in August, 60 in the first 12 days of September.

The backend is entirely untouched (SystemPromptsService, GET /system-prompts/, selected_prompt_id, admin CRUD), so restoring ConversationModePickerComponent is a revert rather than a rebuild. Sessions that already carry a mode keep applying it; no user can select or change one.

Three options: restore the picker before merging, land a replacement placement, or take the regression knowingly. This was recorded as a release gate in docs/specs/customize-surface.md at the time the picker was parked.


Pre-merge gates

  • GSI update limitcheck-gsi-update-limit.mjs PASSED. Exactly one GSI operation (EntityTypeIndex) on the existing {prefix}-app-roles table; 27 tables compared.
  • Pending backfillscheck-pending-backfills.mjs PASSED. backfill_tool_catalog_index.py is named in RELEASE_NOTES.md with its command and verification step.
  • Version syncsync-version.sh --check PASSED. VERSION 1.20.0 → 1.21.0, all manifests, README and three lockfiles regenerated.

Deploy order

platform.yml → wait for EntityTypeIndex to report ACTIVErun the backfillbackend.ymlfrontend-deploy.yml.

The catalog read moves onto that sparse index in this same release. A sparse index answers "nothing matched", not "something is wrong" — an unrun backfill looks like a short tool list, not an outage. There is a zero-result Scan fallback that logs an ERROR naming the script, but it is a safety net, not the plan.

Infrastructure

  • EntityTypeIndex on the existing {prefix}-app-roles table (ProjectionType.ALL, sparse)
  • sagemaker:CreateModel on the app-api task role — Batch Transform inference had never worked from the deployed app
  • CloudFront x-forwarded-prefix: /api — fixes blocked mixed content on every conversation page load
  • agentcore-runtime-active-sessions alarm, threshold 75 over a 60-minute window

Breaking

  • The Conversation Mode regression above
  • /settings/connectors and the three /my-skills paths are now redirects
  • dark:text-primary-400 and bg-primary-50|100|200 banned as text/tint tokens (both fail WCAG AA; the primary scale keeps full chroma at every step)
  • last_login_at accurate to within USER_SYNC_THROTTLE_SECONDS (default 300) rather than to the last request

🤖 Generated with Claude Code

philmerrell and others added 30 commits September 5, 2026 15:28
…ments-user-surface

feat(announcements): user feed, acknowledgements, and What's New panel (PR-2)
Closes the gap §8 of docs/specs/artifact-sharing.md has carried since
the feature shipped: a recipient of a shared conversation saw nothing
where the owner sees artifact cards. Silently — no placeholder, no
error — because artifact hydration filters HEAD rows by the requesting
user. It stayed open because the fix needed a consent decision rather
than wiring. The decision is that sharing a conversation shares the
artifacts it produced.

## The conversation share is the grant

No artifact share records are created for this, which is where the
original sketch in §8 pointed and where this deliberately does not go.

`create_share` pins the session's artifacts — at the version each stood
at right then — into the snapshot body next to the messages. That does
two jobs. It keeps the point-in-time promise the snapshot already makes,
so a recipient reading a frozen conversation is not shown an artifact
the transcript around it never describes. And it makes the snapshot the
ALLOWLIST.

`resolve_shared_artifact` is then the entire access boundary, and does
both halves: may this viewer open this share, and is this artifact one
the snapshot pinned. It hands an owner id and a pinned version to
`mint_for_conversation_share`, which checks nothing itself and says so
in a comment — the token's `sub` is a DynamoDB partition address, so
without the second half any valid share id plus a guessed artifact id
would read the owner's whole artifact partition.

Provisioning parallel artifact shares was rejected on two grounds. Each
would need cascading on update, revoke, artifact delete and session
delete, and a missed cascade leaves an artifact readable after its
conversation was locked down — a security bug, not a display one. It
would also put N rows in the recipient's "Shared with you" inbox for one
conversation share, when the conversation is the thing that was shared.

The payoff is that access has one source of truth: narrow a
conversation's allowlist and its artifacts lock down in the same write;
revoke it and they go with it. Both are tested as such.

## Compatibility

Conversation sharing is in production, so the snapshot's `artifacts` key
is optional on read: bodies written before this exist, and legacy inline
shares predate the S3 offload by a wider margin still. Both read as an
empty list. No migration, no schema bump — the addition is additive.

Capture is best-effort. Sharing a conversation must not fail because the
artifacts feature is off in an environment or its table hiccuped; a
share with no artifacts is what every share was until now.

## Also

`_load_snapshot_body` is now a narrowing of a new `_load_snapshot_raw`,
so a caller wanting another key of the body does not have to widen the
tuple every existing caller destructures.

`heads_for_session` returns one row per artifact at HEAD, off
SessionIndex alone — the shape a snapshot wants, where
`list_for_session` returns one row per version for the session view's
per-turn anchoring.

Backend: 914 passed across app_api + architecture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR-3 of docs/specs/feature-announcements.md. Removes the "author by curl"
step: announcements can now be written, scheduled, published, archived and
re-shown from the admin console.

- `manage-announcements.page.ts` — list with state chips, surface icons, and
  the lifecycle actions an announcement has and a link does not (Publish,
  Show again, Archive). Mirrors `manage-user-menu-links`.
- `announcement-form.page.ts` — title, markdown body with live preview,
  surface checkboxes, severity, schedule, role picker, `showToNewUsers`,
  `requiresAck`, CTA.
- Nav entry under Customization, `data: { scope: 'admin.announcements' }`.

Three server rules are mirrored in the form so an admin finds out before
submitting rather than through a 422: `expiresAt` is required once a banner
or modal is selected, `ctaLabel`/`ctaUrl` travel together and must be
http(s), and the body cap is counted in **bytes** (the server's limit is
16 KB of UTF-8, so a 3-byte character costs three).

The panel checkbox is deliberately rendered checked-and-disabled rather than
omitted. The server forces `panel` on regardless (§D1); showing it explains
why dismissing a banner never destroys the information, where silently adding
it after save would not.

Two lifecycle guards are carried into the UI rather than left to the API:
- Edit never sends `state`, and "Show again" is a separate action from
  editing — a typo fix must not re-fire a modal at everyone (§D4). The
  confirm text says exactly that.
- Publish is not offered on an archived announcement, because the server
  refuses it; offering a button that returns a 400 is worse than no button.

The markdown preview uses `message-block`, the app's real markdown
stylesheet, so what the admin previews is what the panel renders. The `prose`
classes would be inert — the Tailwind typography plugin is not installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ipient

PR-2 of the pair. #971 made the artifacts reachable; this makes them
visible. A recipient now sees artifact cards anchored under the same
turns the owner sees them under, and opens them read-only.

## A separate recipient component, not a mode of the owner's

`ArtifactCardComponent` opens the docked panel and carries download,
share, rename and delete; `ArtifactPanelComponent` adds a version picker
and a code view. Every one of those is keyed on something a
conversation-share recipient does not have — an owned artifact, or an
artifact share id. Bending either into a "read-only mode" would mean a
component whose every action is conditional on a flag, and the failure
mode of a missed condition is a visible button that 403s.

So the card and dialog are their own thing. What IS shared is the layer
that should be: `ArtifactViewerComponent`, which is purely presentational
and already served the owner panel and the standalone recipient page
through two mint endpoints. This is the third, and it needed no change
to that component — which is the sign the split was drawn in the right
place.

## Artifacts arrive as an input, not through the state service

`MessageListComponent` reads artifacts from `ArtifactStateService`, which
is the OWNER's live session state: populated by SSE events and
owner-scoped hydration, neither of which a recipient has. Feeding it
recipient rows would put another user's artifacts into the signal the
real session view reads. So the shared view passes them down, and the
list groups them with the same index-anchoring logic — including the
orphan fallback, so an artifact with no usable anchor lands in the end
strip rather than disappearing, which is the exact failure this whole
feature exists to fix.

## No code view

The source endpoint is keyed on an artifact share id, which a
conversation share does not have. The toggle is therefore absent rather
than present and permanently failing. Adding it is a backend change, not
a UI one.

A refused artifact and a missing one get one message: "not part of this
share" and "you may not open this share" are the same fact to a
recipient, and telling them apart would describe what the owner has.

SPA suite: 2367 passed (+26). 20 new tests: card, dialog (including the
sandbox isolation the third mint path must not weaken, and the
superseded-mint race), and the message-list anchoring — which had no
component spec at all before, so the orphan-fallback branch the owner
path also relies on is now covered.

Verified in the browser against the compiled stylesheet at 1080px in
both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-shared-with-you-tabs

feat(artifacts): All / Yours / Shared with you tabs on the library
…onversation-artifacts

feat(artifacts): share a conversation's artifacts with the conversation
…ments-admin-ui

feat(announcements): admin list and form pages (PR-3)
…onversation-artifacts-ui

feat(artifacts): render a shared conversation's artifacts for its recipient
…ncement form

Found by browser-verifying the page in dev: every field filled, the form
reporting `ng-valid`, and "Create draft" still disabled. No announcement
could be authored from the UI at all.

`canSubmit` is a `computed`, and a computed tracks the signals read during
its *last* execution — so an early `return` shortens its dependency set. The
guard chain read `isSubmitting()` and then `if (this.form.invalid) return
false`, and `FormGroup.invalid` is a plain getter, not a signal. On the first
evaluation the form was empty, so it returned there having tracked only
`isSubmitting`. No later edit could schedule a recompute, and `isSubmitting`
changes only inside `onSubmit` — which the disabled button prevented.

Two changes, both load-bearing:
- form validity is mirrored into a signal fed by `statusChanges`, like the
  other `valueChanges` mirrors already in this component;
- every input is read unconditionally before being combined, so no branch can
  shrink the tracked dependency set again.

The three new tests read `canSubmit()` while the form is still **empty**,
then fill it. That ordering is the whole point: the existing 24 specs only
ever read it after filling, so the computed's first evaluation saw a valid
form, tracked everything, and stayed reactive — all 24 pass against the
broken code. Verified by reverting the fix: the 3 new tests fail, the other
24 do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening "Shared with you" with nothing shared showed "No artifacts match
your search" — with an empty search box. Found on dev the moment the
inbox flag went live.

`isFilteredEmpty` gated on the LIBRARY total, so any non-empty library
made an empty tab look like a failed search. It needed the SELECTED
TAB's count instead. The irony is that the comment above it already
warned about exactly this conflation ("'Nothing matches' is a different
message from 'you have nothing'") — tabs added a third state, "nothing
*here*", and the old gate quietly folded it into the wrong one.

So there are now three, in priority order:

  isEmpty         nothing anywhere        "No artifacts yet" + CTA
  isTabEmpty      nothing in this tab     names the tab
  isFilteredEmpty filtered to nothing     "No artifacts match your search"

`isEmpty` still wins when the library is empty outright: a per-tab
message would bury the one statement that actually matters.

Why the tests missed it: every empty-state spec asserted which ROWS
rendered, never which SENTENCE appeared when none did. The three added
here assert the sentence, including the case where blaming the search
is correct.

SPA suite: 2431 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s-form-submit-gate

fix(announcements): submit button could never enable on the new-announcement form
…ty-tab-message

fix(artifacts): stop blaming a search the user never made
The `banner` surface has been authorable since PR-1 and computed by the
server since PR-2, but nothing in the SPA consumed `bannerItem()` — an
admin who ticked "banner" got a field that did nothing, with no way to
tell from the UI that the surface was unbuilt.

`components/announcement-banner` renders the one banner the server picked
(§D7) as a strip at the top of the shell: severity icon and colour from
the `state-*` scale, the `summary` line when the author wrote one, an
optional CTA, and a ✕ that records a durable `dismissed` ack. It writes
`seen` on render — once per announcement per tab — which is what clears
the unread dot for someone who reads the banner and never opens What's
New. That write races the ✕, and deliberately relies on §D2's monotonic
server-side rank rather than ordering the two client-side.

Placement is a flex child of the shell's `<main>`, above the scroll
container, so content reflows instead of hiding underneath. Three pieces
of viewport-fixed chrome would otherwise paint over it, so the strip
publishes its measured height as `--announcement-banner-height` and they
offset against it: the chat topnav, the full-page empty-state overlay
(which was `inset: 0`), and the two floating sidenav control clusters.
The height is measured rather than hardcoded because the line wraps on
narrow viewports. The voice overlay still covers it, which is right —
that one is a modal.

Gated on `isAuthenticated()`, not just chrome. `AnnouncementsService`
loads its feed on the first read of `bannerItem()` and `resource()` loads
exactly once, so mounting the banner on the login screen would fire
`GET /announcements` unauthenticated, take the 401's empty-feed fallback,
and never retry — announcements would be missing for the life of the tab.
Found in the browser, not by a spec.

Verified end to end against dev data with a local app-api: strip renders
in light and dark and at 375px with no horizontal overflow, ✕ writes an
ack that upgrades the existing `seen` row in place to rank 2 rather than
duplicating it, the server then returns `banner: null` while the panel
entry survives (§D1/§D2), and deleting the ack brings the banner back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner

feat(announcements): render the banner surface (PR-4)
The interruptive surface, and the last one. `components/announcement-modal`
renders the single modal the server picked; `AnnouncementModalService` decides
whether interrupting is acceptable at all.

The gate is the substance of this PR. The modal opens on route settle and only
when there is no active stream, no pending tool-approval / OAuth-consent /
MCP-App-consent prompt, no draft in a focused composer, and the route is not a
minimal-chrome page. The consent checks are not redundant with the stream
check: per mid-turn-steering (#934) `isLoading()` is FALSE while a turn is
paused on an interrupt, so a stream-only gate would throw a dialog over an
OAuth consent prompt and steal its focus. The prompt services are asked
directly.

Every gate input is read `untracked`. Read reactively, the effect would re-run
the instant a stream ended or a consent was answered and fire a modal seconds
after the user finished a thought — which §D8 forbids in as many words: a
failed gate leaves the announcement eligible for the next clean load, it does
not queue it. So the effect tracks only the announcement and a navigation
counter, and snapshots the rest.

`requiresAck` makes the confirm button the only exit: no ✕, `disableClose` on
the overlay, and the in-component Escape and backdrop handlers return without
writing an ack. Belt and braces on purpose — the CDK option and the guards
fail independently. The button label follows the ack it writes, "I understand"
→ `acknowledged` and "Got it" → `dismissed`, so it cannot misdescribe the
record.

Started via `provideAppInitializer` rather than mounted in app.html: a CDK
overlay is not a layout element, and nothing else would ever inject the
service. Same shape as ThemeService. It also means this PR does not touch the
app shell, so it does not conflict with PR-4.

Body uses `.message-block`, not `prose` — the typography plugin is not
installed, so the older user-menu-link-modal's classes are inert and strip
list markers. Sanitization stays on (§D10): `admin.announcements` is
delegable, so this body may be authored by a non-admin and reaches every user.

Verified end to end against dev data with a local app-api. With `requiresAck`:
opens on load, no ✕, Escape and backdrop clicks leave it open, and the button
writes `acknowledged` at rank 3 — upgrading the `seen` row in place rather
than duplicating it. Without it: ✕ and Escape both write `dismissed` at rank
2. Afterwards the server returns `modal: null` while both panel entries
survive (§D1/§D2). Light, dark, and 375px with no horizontal overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-modal

feat(announcements): render the modal surface with the §D8 gate (PR-5)
…end)

`/stats` needs a count of acks across users, which the key shape does not
support: acks live under `USER#<id>` partitions, so counting them per
announcement means a GSI on `announcementId` or a scan. The spec ranks those
second and third and says start with atomic counters on the announcement item
(§9). This does.

The counters are top-level attributes — `ackCountsR1Seen` and friends — not a
nested `ackCounts` map, because DynamoDB's `ADD` only works on top-level
attributes and creates a missing one as 0 in the same atomic write. A nested
map needs `SET path = if_not_exists(path, :zero) + :one`, which raises
ValidationException until the parent exists, so every announcement authored
before this shipped would need an init-then-retry branch on the ack hot path.

They count users, not clicks. `record_ack` now reads the previous rank via
`ReturnValues="UPDATED_OLD"` and bumps only the ranks the write crossed, so
`seen` then `dismissed` adds one to each rather than two to `seen`. They are
a funnel, not a partition: acknowledged implies dismissed implies seen, so
`seen >= dismissed >= acknowledged` holds without ever reading them back.

Keyed by revision, because "Show again" (§D4) is a deliberate re-broadcast and
rolling its acks into the previous revision's totals would inflate them and
make the numbers lie about the version people actually saw.

**The bug worth reading twice:** every admin mutation — `update_announcement`,
`set_state`, `bump_revision` — is a full `put_item` of the `Announcement`
dataclass, so any attribute the model does not carry is destroyed by it.
Publishing an announcement, the most common admin action there is, silently
zeroed every counter. `Announcement.ack_counts` now carries them through
read → write. Four regression tests cover publish, archive, edit, and
continued accrual afterwards.

`targeted` is answerable only for a `"*"` audience, via a COUNT query on the
users table's StatusLoginIndex. That index is projected INCLUDE without
`roles`, so a role-filtered count has nothing to evaluate against, and the
alternatives are worse than an honest null: replacing a GSI on the users table
(CFN reports green well before an index is ACTIVE), or the scan the spec ranks
last. Nor is there a membership list to count — roles arrive as JWT claims
mapped at login. Null means "not estimated", never zero.

Increments are best-effort by design: a second write after the ack is already
durable, logged and swallowed on failure. An under-counted stat beats turning
a successful acknowledgement into a 500.

18 new tests; full backend suite 2329 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes PR-6. The admin list now carries a reach line per announcement —
"2 seen · 0 dismissed — of ~68 targeted (estimate)" — which is the point of
the whole surface: it tells you whether any of this works.

Rendered as a funnel, not a partition. "12 seen · 8 dismissed" means 8 of
those 12, because the stored rank only ever rises through them (§D2).
`acknowledged` appears only where one was actually asked for; on an
announcement without `requiresAck` the number is real but meaningless, and
showing a third figure that is always equal to the second reads as a bug.

Two cases render nothing rather than a zero:

- **A draft.** Nothing has been shown, so "0 seen" would read as "nobody
  engaged" instead of "not sent yet". `hasReach` gates on published/archived,
  which also keeps the fetch off every row an admin is still writing.
- **A role-scoped audience.** `targeted` is null there — the users table's
  StatusLoginIndex does not project `roles` — and "of ~0" would imply nobody
  is targeted. It says "audience not estimated" instead.

Stats are a second endpoint per announcement, so they load after the list
rather than blocking it, and only for rows that have been live. The cache is
keyed by **id plus revision**: "Show again" restarts the counters, so an entry
from the previous revision would report stale reach for a broadcast that has
only just gone out. A failed fetch is dropped from the requested set so the
next pass retries, and leaves the row without a reach line rather than
blanking the list — the page's actual job is CRUD.

The hover text and the "(estimate)" suffix carry the §11 caveat. One more is
now documented on the response model: **nothing is backfilled.** The counters
are incremented by the ack write path, so acks recorded before this ships are
invisible — an existing environment starts every announcement at zero on
deploy day even where people have already read and dismissed it. Verified
against dev, where four ack rows predate the counters and only the two written
since are tallied.

7 new service specs, 8 new page specs; full frontend suite 2486 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dismissing the banner pulled the whole view up by its height. It was a flex
child of the shell's `<main>`, so appearing and disappearing reflowed
everything below it — the jump was the bug, and reserving the space forever
would have been a worse fix.

It is now positioned `absolute` against a `relative` `<main>`: a rounded,
shadowed pill floating over the content rather than a full-bleed strip
displacing it. Measured before and after a dismissal, every content element —
scroll container, greeting, composer — moves by exactly 0px in both axes.

The overlay removes the reason anything had to know the banner's size, so this
deletes more than it adds:

- `--announcement-banner-height`, its `ResizeObserver`, the height signal, and
  the `DOCUMENT`/`ElementRef`/`DestroyRef` injections all go
- `.chat-topnav-wrapper` goes back to `top: 0`
- `.chat-container-empty.full-page` goes back to `inset: 0`
- both floating sidenav control clusters go back to `top-4`

`top-16` is the one constant that replaces all of it, and it is not arbitrary.
On a chat route it lands the pill immediately below the fixed topnav — the
placement §D1 asks for — and everywhere else it clears the shell's floating
sidebar buttons, which sit at `top-4` and would otherwise be overlapped by a
centred pill on any viewport narrow enough for the two to meet. Verified at
375px: the controls end at y=56 and the pill starts at y=64.

The positioning strip spans the full content width, so it is
`pointer-events-none` with `pointer-events-auto` on the pill alone — otherwise
an invisible band would swallow clicks aimed at the topnav and the sidebar
buttons beneath it. Verified: a click 30px outside the pill lands on the chat
container, not the banner.

`relative` on `<main>` is load-bearing. Without it the pill anchors to the
viewport and drifts out from under the sidenav's padding transition.

Browser-verified against dev data in light and dark and at 375px, with no
horizontal overflow. Full frontend suite 2474 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-stats

feat(announcements): reach stats — counters, GET /{id}/stats, admin list (PR-6)
…-wrong blend

`--rates-only` could never have produced a usable number. Three defects, all
found by actually running it against dev-ai:

1. It filtered usage types on the substring `gpt-5.6`. No usage type contains a
   model id, so the filter matched nothing and the script reported "Cost
   Explorer lags ~24h" — a lag message for a search that was never going to
   match, which is the worst possible failure mode for a tool whose whole job
   is to answer "have the numbers landed yet?".
2. It multiplied every rate by 1000 to convert from 1K-token units. These
   models bill through AWS Marketplace in units of **1M tokens**, and Cost
   Explorer declares the unit in its own `Unit` field. Every derived rate was
   overstated 1000x. It now reads the declared unit and converts accordingly.
3. It read MONTHLY. Daily rows come back as exact round numbers; a multi-day
   window silently blends models into an average that looks like a rate.

The blend is not hypothetical, and it is why this needed a guard rather than a
fix. Marketplace usage types carry the token bucket and the service tier but
never the model, so every OpenAI-family model in the account shares the same
four rows — verified against USAGE_TYPE grouped by OPERATION and by
BILLING_ENTITY; no finer dimension exists. August shows two distinct price
cards ($5.50/$27.50 and $2.20/$11.00) and 2026-08-31 is visibly a blend of the
two. A rate is therefore only a given model's rate on a day when it was the
sole OpenAI-family model to run, so `--table` now reconciles against what we
recorded in sessions-metadata and refuses to vouch for a number otherwise.

I nearly shipped the mistake this guard prevents: a first read of Aug 20-31 gave
a cache-read rate matching gpt-5.4's 0.1x to four decimals, and a reconcile then
showed zero GPT calls in that window. The match was coincidence.

This also closes off the spec's Option 1. The Price List API has no Marketplace
service code at all (all 269 enumerated), and the Marketplace Catalog API is
seller-side. These rates are not unpublished-yet; they are unpublishable through
any pricing API while they bill this way, so waiting will not produce them.

Bearing on the tier/long-context modelling gap PR-3 must resolve: every row ever
seen in this account is `_standard` and no `-long-ctx` usage type has appeared,
so a flat standard rate is correct for current traffic and a change would show
up as a new usage type. That makes the gap monitorable rather than blocking.

Controlled window claimed 2026-09-06 for `us.openai.gpt-5.6-sol` (dev had zero
recorded calls beforehand); expected token totals are recorded in the spec so
the read is a verification rather than a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner-overlay

fix(announcements): float the banner instead of occupying layout
…odel cards

I concluded yesterday that these rates existed in no source and had to be
derived empirically. That was wrong, and the error was one of scope: the search
ran against pricing *APIs* — Price List, then Marketplace Catalog — and stopped
there. AWS publishes them in prose on each model's card in the Bedrock User
Guide, alongside caching support, context windows, service tiers and endpoint
support. Absence from an API is not absence from the docs.

Every dev GPT-5.6 row was wrong, and every error over-charged by exactly 20%
(corrected in the dev catalog 2026-09-06T15:59Z):

  sol    output                     26.40  ->  22.00
  terra  in/out/cache read/write    2.64 / 15.84 / 0.264 / 3.30
                                 ->  2.20 / 13.20 / 0.22  / 2.75
  luna   in/out/cache read/write    0.264 / 1.584 / 0.0264 / 0.33
                                 ->  0.22  / 1.32  / 0.022  / 0.275

The 1.2x is not coincidence: Terra and Luna were sourced wholesale from the
GovCloud Price List rows, which are exactly 1.2x commercial. Sol's output was
the one figure with no source at all — a 6x input ratio inferred from GovCloud,
where the real ratio is 5x. `openai.gpt-5.4` was already correct, empty
cache-write cell included, so yesterday's prod fix is confirmed by the card.

This also resolves the tier/long-context gap PR-3 was blocked on, rather than
merely downgrading it as the previous commit claimed:

- Service tiers do not apply. Every card says Priority and Flex are not
  supported for these models, so the 0.5x/2x dimension does not exist here.
- Long context is real, and the spec's "2x twin" was wrong: above the 272K
  threshold input is 2x but output is only 1.5x. A flat 2x would have
  over-priced long-context output by a third.
- We do not reach it. All rows carry maxInputTokens 272000, pinned at the
  short-context boundary, and compaction runs at 100K — so one short-context
  rate is correct, and that cap is what keeps it correct.

Noted for the prod rows: `global.openai.gpt-5.6-*` prices 9.1% below the `us.*`
Geo CRIS card across every bucket, and prod already runs Claude on `global.*`.
Prod should not be a copy of the dev rows.

The empirical work in the previous commit is not wasted — it is now the audit
of these published numbers instead of the source of them, and the 2026-09-06
Sol window should reproduce 4.40 / 0.44 / 5.50 / 22.00 rather than discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `CURATED_BEDROCK_RESPONSES_MODELS` behind a new "Bedrock Responses"
catalog tab, so the three GPT-5.6 models are one-click-creatable instead of
requiring the escape-hatch form. Rates are the published Geo CRIS
short-context row from each AWS model card — Geo CRIS is the tier the `us.*`
inference profiles resolve to, and these models are inference-profile-only.

Two values are pinned by test because they are pricing correctness, not
preference:

- `supportsCaching: true`. These models cache implicitly server-side with no
  way to turn it off, so `false` is not a preference but a false statement,
  and its only effect is to clear the cache-rate fields — pricing cached
  tokens at $0.00 while AWS bills them in full. On a warm conversation nearly
  every input token is a cached one.
- `maxInputTokens: 272_000`. These have a 1M window but AWS prices them on two
  cards: above 272K, input costs 2x and output 1.5x. A CuratedModel holds one
  flat rate per bucket, so this cap is what keeps that single rate honest.
  Raising it silently opens the second price card.

Fixes the curated `openai.gpt-5.4` Mantle entry in the same pass. It inherited
`mantleDefaults()`' `supportsCaching: false`, so one-click-creating it produced
exactly the mis-priced row that had to be repaired by hand in prod last night.
Its card publishes a cache-read rate at 0.1x input and an em dash for cache
write, so caching is on with a literal 0 write rate — 0 is the correct value
rather than a missing one, because it makes `compute_wasted_usd` see a
non-positive premium and return $0 instead of inventing waste. The
`mantleDefaults()` comment claiming Bedrock caching is model-bound to
Claude+Nova was simply wrong and is corrected.

`claudeRates` becomes `ratesWithDerivedCache`: the 1.25x write / 0.1x read
multipliers are not Claude-specific. The GPT-5.6 cards publish the same two,
and commercial Cost Explorer billing reproduces them to four decimals — two
model families, two independent sources, same ratios.

`supportedParams` is deliberately absent from the new entries. AWS publishes no
parameter table for GPT-5.6 (`model-parameters-openai.html` covers only the
open-weight gpt-oss family), and a declared spec flips the #915 guard from
permissive to restrictive — so an invented one would silently block parameters
the model actually accepts. Better none than a guess.

Not browser-verified: the page is admin-gated against the dev backend, so an
unmerged frontend change cannot be signed in to. Layout risk is low — the tab
strip is `flex-wrap` and the card grid is unchanged — but the visual check is
worth doing on dev after merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erivation-method

GPT-5.6: correct every rate from the model cards, curate the models (PR-3), and fix the tool that missed them
The banner sat at the top of the shell. What it announces — a new model, a
new capability — is acted on in the composer, so the notice now lives where
the decision is made rather than in a corner the eye has already left. It
mounts from `chat-input` beside `quota-warning-banner`, which is where users
already look for ambient notices.

It still floats rather than stacking in flow. `bottom-full` against a
`relative` chat-input host puts it clear of the quota tabs, which stay
attached to the input, and keeps the property from the previous change:
measured before and after a dismissal, the composer, greeting and scroll
container all move by exactly 0px. Restyled to match the sibling it now sits
beside — a compact shrink-to-fit pill rather than a bar spanning the composer,
which also shrinks how much it overlays.

**It is now a chat-view surface only.** That is the real consequence of the
move and it is deliberate: What's New remains the everywhere-record, which is
why `panel` is forced onto every announcement server-side. The spec's §D1 is
updated to say so rather than leaving the doc describing a placement that no
longer exists, and the two admin help strings that told authors "a strip below
the top nav" now describe where a banner actually appears.

`chat-input` is reused by the agent-preview and marketplace test-drive panes,
where a platform-wide notice would read as a bug rather than an announcement.
A `showAnnouncements` input gates it, following the same opt-out shape as the
`show*` controls beside it: default true, explicitly false at those two call
sites, and threaded through `chat-container` so its embedded mode is off too.

The shell mount and its `isAuthenticated()` gate are gone with it — the
composer only exists inside an authenticated chat route, so the 401-on-login
hazard that gate existed for is now structural rather than guarded.

Three real test failures found and fixed on the way: two `chat-container`
specs stub `app-chat-input` and needed the new input added to the stub, and
one of the banner's own assertions was stale after the restyle. Full frontend
suite 2486 passed, only the known `submission-review` flake outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer is not always in the same place. A conversation pins it to the
bottom of the viewport, where a pill below it would be off the edge. The empty
state centres it with the greeting immediately above, where a pill above it
floats over that greeting — visibly so at 375px, where the greeting wraps and
the pill covered its second line.

So the placement follows the composer: `below` on the empty state, `above`
otherwise.

Derived, not measured. `isEmptyState()` is the same computed that already
picks which layout branch renders — the centred composer or the bottom-pinned
one — so reading it makes the two impossible to drift apart. Measuring the
composer's viewport position would re-derive that same fact less reliably and
would have to be recomputed on resize, on scroll, and when the artifact pane
opens.

The banner takes a `placement` input and swaps `bottom-full`/`mb-2` for
`top-full`/`mt-2`; `chat-container` supplies it through `chat-input` alongside
the `showAnnouncements` gate.

Verified on the empty state at desktop and 375px: the pill sits below the
composer in clear space and `coversGreeting` is false in both, where it was
true before. Full frontend suite 2490 passed — a clean run, including the
`submission-review` spec that has been flaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner-near-composer

feat(announcements): move the banner to the chat composer, on whichever side it leaves free
Groundwork for serving the library's user-wide listing from an index
instead of a base-table Query. Nothing reads it yet — the query switch
ships separately, for the deploy-ordering reason below.

## Why an index at all

The base table is already partitioned by user, so this was never about
reachability: `list_for_user` can Query PK=USER#{uid} today. It is about
what that Query has to read. HEAD and version rows share the partition,
so it spans roughly 3x the rows it returns, then filters and date-sorts
them in memory — which is also what makes it unpaginable, since the page
boundary would fall in the wrong place. Only HEAD rows carry GSI2PK, so
the index holds one row per artifact, already newest-first.

## The backfill is the load-bearing half

`UserArtifactsIndex` is sparse. A HEAD row without GSI2PK is not stale
in it — it is absent from it forever, silently. The writer began
stamping the keys on 2026-09-04; every row written before that has
neither attribute, so switching the query without backfilling would drop
every older artifact from its owner's library with no error anywhere.

`backfill_artifact_user_index_keys.py` stamps them. Dry-run by default,
idempotent (`attribute_not_exists(GSI2PK)`, so it also yields to the
writer), and it never resurrects a row deleted mid-run
(`attribute_exists(SK)`).

It reads `updated_at` to build GSI2SK and never assigns it — that
attribute is embedded in both GSI sort keys and is writer-owned, the
same restraint `rename` observes. A HEAD row lacking `updated_at` is
reported by name rather than stamped with a fabricated timestamp that
would sort it wrongly forever.

Run on dev: 22 HEAD rows, 22 stamped, 0 skipped, 0 failed; re-run
reported 22 already-stamped, 0 stamped. Verified independently
afterwards: 0 rows missing GSI2PK, 0 version rows wrongly stamped, and
GSI1SK == GSI2SK on all 22 — GSI1SK is the *writer's* own
`ARTIFACT#{updated_at}#{aid}`, so that agreement checks the backfill's
format against production-written data rather than against a reading of
the code.

## Deploy notes

Adding this is one `UpdateTable`, and only one GSI may be added per
`UpdateTable` — the committed `gsi-inventory.json` gains exactly one
line here, so no release split is needed.

CFN reporting UPDATE_COMPLETE does NOT mean the index is ACTIVE;
DynamoDB backfills it asynchronously. That is why the query switch is a
separate PR: platform.yml and backend.yml share a concurrency group but
their order is not enforced, so shipping both together risks app-api
querying an index that does not exist yet.

Infrastructure: 784 passed. Backend: 186 passed across the artifact
suites, including 10 new tests for the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…evelop-1.18.0

# Conflicts:
#	backend/src/apis/app_api/fine_tuning/routes.py
#	backend/tests/fine_tuning/test_job_guards.py
philmerrell and others added 22 commits September 13, 2026 07:21
…ze-skill-detail

feat(customize): add a skill detail view
Imported from an in-flight worktree (claude/style-consistency-views-7231a9)
so the follow-on radius sweep can build on it in a single branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-on to the Agents/Customize/Artifacts pass. Settles the radius split:
`rounded-2xl` is now the single button radius app-wide — user-facing pages,
admin lists and forms, and dialogs alike. The previous two-radius-by-surface
convention is what let pre-redesign `rounded-sm`/`rounded-md`/`rounded-lg`
buttons drift in between the two documented idioms.

- 45 off-token primary buttons -> rounded-2xl
- 3 more found where the radius and fill were not adjacent in the class
  string (login, first-boot, user-detail) and so missed a naive grep
- 6 dialog Cancel/secondary buttons -> rounded-2xl, plus the shared
  ConfirmationDialog confirm/cancel pair
- retargets the 6 buttons the prior pass had set to rounded-xl

Also folds in the contrast-safe fill alias: solid `bg-primary-500|600` with
white text becomes `bg-primary-accessible` + `hover:brightness-95`, dropping
the now-redundant `dark:` fill pairs. Identical hex today, but only the alias
is AA-guaranteed after a rebrand.

Deliberately unchanged: segmented-control shells and their `rounded-lg`
children, `rounded-full` chips/badges/search/pill CTA, `rounded-2xl` cards,
the user-message bubble geometry, and two `bg-primary-50` tints that are a
drag overlay and a "Latest" badge rather than buttons.

Frontend suite: 239 files / 2824 tests passing, unchanged from develop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At the lg breakpoint with the sidenav open at 1280px each card lands at
roughly 340px. The customize-card spends 36px on the monogram, 44px on the
fixed-width toggle plus gaps, leaving ~230px for the title — so real tool
names truncate ("PowerPoint Prese…", "Student MyBoiseS…", "Salesforc…").

Found and fixed by the session behind claude/style-consistency-views-7231a9;
reproduced here at 1280px against dev data before applying, and confirmed the
three names render in full afterwards. Only visible against real data, which
is why the isolated-token screenshots missed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass swept primary fills, which the handoff inventory had listed.
Their bordered partners were never inventoried, and the browser preview made
the gap obvious: on the 404 page "Return Home" rendered at 16px next to a
"Go Back" still at 8px. That one escaped the source grep because it uses a
translucent `bg-white/60` rather than a flat `bg-white`.

Sweeps the remaining 73 button-like call sites (59 <button>, 14 <a> used as
buttons, 3 <label> file-pickers) onto rounded-2xl.

Excluded, deliberately, after checking each element's tag and role:
- two <input> and one <select> that matched only on font-weight
- the sidenav and settings nav items (routerLinkActive highlights, not
  buttons — same family as the sidenav item left alone in the first pass)
- the role="radio" icon-picker segment in model-form

Verified in the browser against dev data in both themes: every button-like
element on Agents, Artifacts, Customize, Fine-Tuning and the 404 page now
computes a 16px radius, with no off-token hits.

Frontend suite: 239 files / 2824 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-shtern-9df427

style(spa): one page-chrome idiom and one button radius across the app
An MCP server exposes three listings — tools, prompts and resources. We
discover and display all three, but prompts were inventory only:
`prompts/get` was never called anywhere in the stack, so a prompt could be
read about and not used.

This makes them usable at the smallest surface that proves the path. On a
tool's detail page each prompt becomes "Try it" → a field per argument →
"Compose" → the server's composition rendered inline, with copy.

Argument capture was lossy. `_prompt_entries` flattened MCP's
`PromptArgument` to names alone, dropping `required` and `description` —
enough to describe a prompt, not enough to fill one in. `MCPPromptArgument`
now carries all three. `from_dict` accepts a bare string so snapshots taken
before this rehydrate rather than break, and the SPA normalizes the same
shape because the two packages deploy on separate workflows whose order is
not enforced.

Resolution is deliberately live and never persisted: the result depends on
arguments typed a moment ago and, for a 3LO server, on a token only the
caller holds. It is capped at 20k chars / 20 messages. Non-text content
(image, audio, binary resource) is named by kind rather than dropped
silently; an embedded *text* resource still flattens to readable text.

The route lives on app-api, not inference-api: this is user-facing CRUD, not
part of the AgentCore Runtime invocation path, and a route added there would
404 in cloud before reaching the container.

No composer change. Wiring a composed prompt into a conversation needs a
prefill path the SPA does not have; that is out of scope here. Nothing this
adds reaches the system prompt, `toolConfig` or history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes ConversationModePickerComponent from the composer and deletes it.
The placement works — verified end to end on dev — but a permanent composer
slot is a bigger commitment than the evidence currently supports, so it comes
out until there is user feedback to decide the location on.

Only the control is removed. SystemPromptsService, GET /system-prompts/,
selected_prompt_id on SessionPreferences and the admin CRUD are all untouched,
so restoring this is a revert rather than a rebuild.

Release gate, recorded in the spec: main still carries the model-settings
drawer, which is how prod selects Guided Learning today (~60 sessions/month
and growing). develop deletes that drawer and now has no picker, so the first
release carrying both leaves no way to select a mode at all. Restore the
picker, land a replacement placement, or take the regression knowingly before
that release ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mpt-try-it

feat(tools): try an MCP server's prompts from the tool detail page
…nversation-mode-picker

feat(composer): park the Conversation Mode picker
`/my-skills` was a top-level route reachable only by a link-out from
`/customize/skills`, so the same noun lived in two places with two different
answers to "what skills do I have?" — one page listed what you authored, the
other what you could turn on, and neither showed the whole set.

Both now live under `/customize/skills`, split by a `scope` query param:

- **Yours** (default) — skills you authored at any status, plus catalog skills
  you have turned on. Dense rows, with edit/delete on the ones you own.
- **Discover** — catalog skills your roles grant that are still off. Browse
  cards with a switch.

Turning a skill on is this platform's analogue of "installing" one: access is
RBAC and the only state a user owns is the enablement preference.

An `Add` menu replaces the old "New skill" button and the link-out, offering
*Upload skill* (`?import=1`) and *Create a skill*. It is gated on the same
404-from-`/skills/mine` signal that used to hide the whole `/my-skills` page.

No backend change. The page merges two endpoints that already existed:
`GET /skills/` (accessible + ACTIVE, with the preference) and
`GET /skills/mine` (the authored tier at every status). The merge is what keeps
a DRAFT skill visible to its author — widening `GET /skills/` to carry drafts
would surface them in the composer picker, which the runtime refuses to
activate. A draft therefore has no toggle at all rather than a dead one.

The authoring form moved to `/customize/skills/{new,:id/edit}` (git mv); the
three old `/my-skills` paths stay as redirects.

⚠️ `customize/skills/new` must stay declared above `customize/skills/:skillId`
or the parameterised route swallows it.

⚠️ `setScope` needs `relativeTo` on `router.navigate([])`, or the empty command
list resolves against the root and the query params are silently dropped. Found
in the browser, not by the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…date-skills-surface

feat(customize): consolidate skills into one surface with Yours/Discover
Typing `/web-research` in the composer invokes that skill for that message —
the sibling of the `@`-mention, with the same menu shape, keyboard and
"rides one turn" semantics. The menu's last row is "Browse skills →".

Scope is the skills the user already has turned on, which is what makes it
cheap: the invoked skill is already in `enabled_skills`, so the system
prompt, toolConfig and `<available_skills>` block are byte-identical whether
or not a command was used. The cacheable prefix is untouched; the whole cost
is one line appended to the turn's user message.

The text is the binding. Unlike the `@` menu there is no remembered pick —
the invoked set is derived from the composer text, so a hand-typed command
works like a menu pick and the chip cannot disagree with what is sent. The
chip's ✕ edits the text, because that is where the binding lives.

`/` is ordinary punctuation, so a command must start a word AND not be
followed by another `/`. That second clause is what keeps `/usr/bin/env`
prose: an absolute path starts a word exactly like a command does. The rule
is implemented three times (composer token, `findSkillCommands`, thread
renderer) and all three must agree.

Backend: `GET /skills/` now serves the runtime activation `slug` rather than
letting the SPA re-derive it; `invoked_skills` on the invocation request is
intersected against the turn's effective set (re-run after Agent bindings
can replace it) and becomes a directive appended last, riding
`original_message` so the thread shows only what the user typed.

Contrast: the chip and menu tile use neutral surfaces with the brand blue in
the text. `bg-primary-50` is not a tint — the primary scale offsets lightness
only and keeps full chroma, so it resolves to rgb(118,179,255). Measured at
10.60/4.74 (light/dark) for the label, all elements above their AA bar.

Spec: docs/specs/skill-slash-commands.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `primary` scale is generated from #0033a0 by lightness offset alone and
keeps full chroma at every step, so `primary-50` is not the pale wash its name
implies — it resolves to rgb(118, 179, 255), a saturated mid-blue. Used as a
chip, badge, icon tile or selected-row fill it reads as a blue blob behind
small text, and it fails WCAG AA. The `state-*` scales ARE real tints
(`state-success-50` = rgb(240, 253, 244)), which is why the pattern looked safe
by analogy and wasn't.

Measured against the app's own resolved tokens:

  text-primary-accessible on bg-primary-100      4.13:1  FAIL
  ...with hover:bg-primary-200                   3.52:1  FAIL
  text-gray-500 sub-label on bg-primary-50       2.23:1  FAIL
  text-primary-accessible-dark on the dark half  2.63:1  FAIL

Replaced 47 opaque `bg-primary-50/100/200` fills and `hover:` affordances
across 25 files with the neutral-surface pattern already established by the
skill-command chip: `bg-gray-100` + `text-primary-accessible` in light,
`bg-gray-700` + `text-primary-50` in dark (9.63:1 / 4.74:1). Sub-labels that
the new fills brought closer together were stepped with them — `text-gray-500`
on `bg-gray-100` is 4.39:1, so those move to `text-gray-600`/`dark:text-gray-300`.
`hover:brightness-90` goes too; it only ever existed to tame the blue.

Left alone deliberately: fractional washes (`bg-primary-50/40` composites to
rgb(200, 225, 255), a genuine pale tint) on large transient drag-and-drop
surfaces, and every solid `bg-primary-500`+ fill with white text.

Guardrails so this does not come back: branding/README.md recommended
`bg-primary-50` as the decorative-tint utility — that row is what propagated
the pattern, and it now documents the neutral one. The tailwind-ui skill's
app-conventions reference gains the same rule with the measured numbers.

Verified in both themes with computed styles: all 17 shipped pairings pass.
2868 SPA tests pass; development build is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lash-commands

feat(composer): slash commands to invoke enabled skills
…-tint-fills

fix(spa): stop using the primary scale as a light-tint background fill
Left over from #200. Every runtime consumer already imports
apis.shared.sessions.metadata; the copy still wrote the legacy rotating
S#ACTIVE# session sort key that the SessionRecencyIndex (GSI4) listing
cannot see. Repoint test_cache_savings.py and three spec citations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ad-app-api-metadata

chore(app-api): remove dead sessions/services/metadata.py duplicate
`@` and `/` are the two shortcuts nothing on the page advertises: each one
only reveals itself once you have already typed the character that opens
its menu. The empty composer is where a user looks when they do not yet
know what to type, so that is where the hint goes.

Three rules keep it from being the kind of animation people file bugs
about:

- It is decoration, not information. The native `placeholder` attribute
  never rotates, so assistive tech reads one stable string; the visible
  line is an `aria-hidden` overlay painted over a placeholder that is
  transparent but still there. A placeholder that re-announced itself
  every few seconds would be a screen-reader defect, not a feature.
- It stops. Three passes and it comes to rest on the idle line, and the
  first keystroke settles it on the spot. Nothing auto-updates
  indefinitely, so WCAG 2.2.2 asks for no pause control we would then
  have to fit into the composer's chrome.
- It honours `prefers-reduced-motion`. Reduce means no rotation at all,
  not the same rotation with the fade taken off.

The rotation comes to rest *in the overlay* rather than unmounting back
to the native placeholder: unmounting would exit-animate a copy of the
idle line straight off the placeholder underneath, which spells the same
words — a ghost double-image on the one transition every user sees.

Hints are offered only for surfaces this composer actually has, so an
environment with Agents switched off, or a user with no skills enabled,
is never told to type a character that opens an empty menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds twenty greetings bucketed into morning (05:00-11:59), afternoon
(12:00-16:59), evening (17:00-21:59) and night (22:00-04:59), each with a
no-name fallback for signed-out users.

They pool *with* the existing five rather than replacing them, so a
morning visitor draws from morning plus the any-time list: about half of
what anyone sees knows what time it is, and half is the familiar copy.
Replacing instead of pooling would leave the app with exactly one thing
to say each morning.

The clock is the viewer's own, read once at resolve time, so "Good
evening" means evening where the person actually is and the heading does
not re-write itself out from under someone mid-conversation.

`DEFAULT_GREETING_TEMPLATES` and `DEFAULT_FALLBACK_GREETINGS` are left
untouched: `brand.defaults.golden.spec.ts` pins them verbatim as the
pre-branding-refactor greetings, and that guard is worth more than
tidiness. The new copy lives in its own constants.

Greetings are Brand_Config territory, so this stays rebrandable.
`timeOfDayGreetings` / `timeOfDayFallbackGreetings` are optional: omit
either, or any single bucket, and the defaults apply. An explicitly empty
bucket is honoured as "stay quiet at that hour" — the one place these
differ from the flat lists, where empty means "have the defaults" — and
each bucket normalizes independently, so one bad hour never costs a
rebrand the other three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r-hints-and-greetings

feat: rotating composer hints and time-of-day greetings
Global preferences get a home. Customize (/customize/{tools,skills,connectors})
replaces the composer's settings drawer, which had been presenting durable,
account-wide state as "settings for this conversation." Tools and skills gain
detail pages, an MCP server's sub-tools can be switched one at a time, and
/skill-name in the composer invokes a skill for a single message.

Chat stops guessing about itself: a four-tool answer renders as one card
instead of five, the loading indicator states what the agent is actually doing
from a new agent_status event, and each finished tool batch gets a
model-written summary from a Nova Micro side-channel that never touches the
cacheable prefix.

Three cost reductions land on the same request path — the tool catalog moves
off a full-table Scan onto EntityTypeIndex, four tenant-global catalogs gain a
TTL + single-flight cache, and the per-request user-profile upsert is throttled.

- Requires a CDK deploy: one GSI operation on the existing app-roles table,
  sagemaker:CreateModel on the app-api task role, a CloudFront
  x-forwarded-prefix header, and one new CloudWatch alarm.
- Requires a backfill: backfill_tool_catalog_index.py, in every environment,
  after the index reports ACTIVE and before the catalog read is trusted.
- BREAKING (UX): the settings drawer is deleted and the Conversation Mode
  picker was parked before release, so this release leaves no way to select a
  mode. Prod uses Guided Learning (~60 sessions in the first 12 days of
  September). The backend is untouched; restoring the control is a revert.
- Fixes blocked mixed content on every conversation page load, and the agent
  designer preview silently dropping tool calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell requested a review from a team September 14, 2026 03:13
Comment on lines +129 to +132
- name: Install
working-directory: frontend/ai.client
run: npm ci --prefer-offline
- name: Run tests with coverage
Comment thread backend/src/apis/shared/caching/config_cache.py Dismissed
try:
key = store.put(model_id=model_id, content=data, ext=ext, content_type=content_type)
except IconStoreError as e:
logger.error(f"Icon storage unavailable for model {model_id}: {e}")
if previous and previous != key:
store.delete(previous)

logger.info(f"🖼️ model-icons: uploaded icon for model {model_id}")
if previous:
get_model_icon_store().delete(previous)

logger.info(f"🖼️ model-icons: removed icon for model {model_id}")
user: User = Depends(get_current_user_from_session),
) -> SkillDetailResponse:
"""Read one skill the current user can reach, catalog or self-authored."""
logger.info(f"User {user.name} reading skill '{skill_id}'")
return await asyncio.to_thread(_get)
except Exception as exc: # noqa: BLE001 - surfaced as a 502 by the route
logger.warning(
"prompts/get failed for %s/%s: %s", tool.tool_id, prompt_name, exc
import argparse
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
Comment on lines +27 to +38
from apis.shared.images.icons import ( # noqa: F401 - re-exported for existing importers
ICON_MAX_BYTES,
ICON_MIN_SOURCE,
ICON_SIZE,
ICON_SQUARE_TOLERANCE_PX,
IconError,
IconStore,
IconStoreError,
content_digest,
key_version,
normalize_icon,
)
Comment on lines +42 to +52
from apis.shared.images.icons import ( # noqa: F401 - part of this module's surface
ICON_MAX_BYTES,
ICON_MIN_SOURCE,
ICON_SIZE,
IconError,
IconStore,
IconStoreError,
content_digest,
key_version,
normalize_icon,
)
# A batch cannot realistically fan out past this; the cap keeps the id list
# from turning a tiny row into a large one.
_MAX_TOOL_USE_IDS = 24
_KEY_ATTRS = ("PK", "SK", "GSI_PK", "GSI_SK", "ttl")
@@ -0,0 +1,193 @@
import { Injectable, computed, inject, signal } from '@angular/core';
@philmerrell
philmerrell merged commit 51fb1d5 into main Sep 14, 2026
14 of 15 checks passed
@philmerrell
philmerrell deleted the release/1.21.0 branch September 14, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants