Sasha Apps: Milestone 2 runtime and operations
Sasha Apps are native HTML, CSS and JavaScript packages discovered below<DOCS_ROOT>/apps/<app-id>. APP.md defines their manifest and maintenance
brief. The sidebar opens one app at a time through the MCP Apps host. One app is
bundled: Time Sheet Entry. (Email Drafter, Candidate Comparison and Report
Builder were withdrawn from the bundle on 2026-09-14 and remain only as
Playwright fixtures under claudecodeui/e2e/fixtures/apps; the seeder archives
an untouched on-disk copy into .retired-bundled-apps/apps/.) Time Sheet Entry
is Milestone 2's database-backed vertical slice: project time entries persist per user (owner-scoped) against a shared
project list, through the governed app data service below. Chat requires the
active project's available chat UI.
Milestone 1 shipped resource discovery, isolation, chat streaming and document
upload, plus action descriptor validation with no execution. Milestone 2 adds
the registry/grant model, the governed per-app SQLite data service, declarative
App Action execution, and the MCP surface for both. The
PRD describes the complete target; its future method
list is not a shipped API list. Drafts, publication, revision transitions, CSV
import/export and historical/audit reads remain Milestone 3/4 โ see the
explicit boundary at the end of this section.
Source, revisions and data
A package contains APP.md, index.html, styles.css and app.js, with
optional contained local assets, actions/ and skills/. A data-backed app
additionally ships schema.sql (schema only โ the statement allowlist forbidsINSERT/ATTACH, so a package can declare structure but never seed rows) and
one or more ordered migrations/NNN-*.sql files whose replayed effectassertReplayEquivalence proves matches schema.sql exactly. IDs use
lowercase kebab-case, up to 60 characters. Paths are relative POSIX paths;
traversal, percent escapes, backslashes and symlinked package components are
refused. Unknown manifest fields are rejected. Declare chat and files as
booleans only when needed; declare data as false (the default), 'read'
or 'read-write'.
Source remains build-free. The platform parses HTML/CSS/JavaScript, assembles the
pinned MCP Apps runtime and local assets, and hashes the final MIME/HTML/CSP/
permission bytes. Source reads allow 64 distinct files and 1 MiB; assembled UTF-8
HTML, including the SDK, separately has a 1 MiB maximum. External dependencies,
inline source scripts/handlers/styles, imports, frames and form destinations are
refused. App CSS and markup remain fully customisable.
Each resource has MIME text/html;profile=mcp-app and an immutable URI:
ui://sasha/apps/time-sheet-entry/revisions/<64-character-sha256>
GET /api/apps returns public descriptors, each item's per-caller access
(use/manage, from the registry โ see below) and the configuredsandboxOrigin. GET /api/apps/:appId/resource?revision=<sha256> delivers
exact resource JSON to the trusted Studio host, with Cache-Control: no-store.
Both routes require a user session with chat:use; API-key/system principals
are refused. Anonymous requests receive the normal 401; invalid credentials,
denied access and invalid/missing/stale resources use the hidden 404 boundary.
Temporary session store failures retain the existing retryable 503.
MCP tools/list exposes authenticated model-visible open_app__<encoded-id>
openers linked through _meta.ui.resourceUri. resources/read returns the exact
same HTML/MIME with CSP and permission metadata. Historical resources live in a
bounded process-local cache: old URIs return their original bytes orAPP_REVISION_UNAVAILABLE after eviction/restart, never current source under an
old identity. This cache is not the future durable publication ledger.
The service separately retains every current catalogue revision, so discovery
remains immediately readable even when the catalogue exceeds the history cache.
Unchanged current revisions keep their exact object identity; a fresh discovery
releases pins for removed packages. Existing MCP snapshots keep their own exact
resources even after the shared history evicts them.
The registry and grant model
Every discovered app is registered in the core database (never the per-app
SQLite file) with its current revision, schema hash and public action
catalogue. On an app's first discovery, manage is auto-granted to every
admin- and staff-role user (owner rulings Q2/Q3) โ never re-run for a
returning app id, so a later downgrade or revoke is never silently restored.
A member gets no automatic grant at all, and M2 ships no grant UI, so a
member's Apps sidebar is empty until an admin or staff user grants use
through PUT /api/apps/:appId/access/:userId (see the operator note below).
Access is read fresh on every call โ a revocation takes effect on the very
next request, with no cached grant and no token reissue needed. Every MCP
data/action tool additionally requires the apps:invoke scope on the calling
token; a caller without it sees no app tools at all, independent of the
per-app use/manage grant.
Source and business data are separate
Business data never lives below DOCS_ROOT (the served docs tree) โ it lives
below a distinct APP_DATA_ROOT, one SQLite file per app id
(<APP_DATA_ROOT>/<app-id>.sqlite), checked for that separation at boot
(assertAppDataRootServable): the server refuses to start if APP_DATA_ROOT
resolves inside DOCS_ROOT, because every app database would otherwise be a
downloadable static file. UI revision rollback never rewinds operational
records. Browser code receives no SQL, database path, credentials or
caller-selected app identity โ every operation is a governed call throughSasha.data.*/Sasha.actions.call(), described next.
The governed data service: query and aggregate grammar
Sasha.data.list(table, query), .get(table, id), .aggregate(table, aggregation), .create(table, values, options), .update(table, id, changes, options) and .delete(table, id, options) are the only ways an app reads or
writes its declared tables โ every call is re-validated server-side against
the table's access policy, never trusted from the client. Bounds: where
up to 20 clauses (eq/neq/lt/lte/gt/gte/in/contains/starts-with,in up to 50 values), orderBy up to 3 fields, select up to 50 fields,limit up to 200 (default 50), groupBy up to 3 fields, metrics 1โ10
entries (sum/avg/min/max/count/countDistinct, exactly one
operation per metric). An aggregate query returns { groups: [...] }
(one row per group, or one row for an ungrouped aggregate). A mutation'svalues/changes map is 1โ50 properties. Every identifier โ table, column,
field โ is resolved against the table's declared column set before any SQL is
emitted; an undeclared field is APP_FIELD_UNDECLARED, never a raw SQL error.
Owner vs. shared access. A table declares access: owner with anownerField (every row is scoped to context.actorId โ a create stamps it
automatically, every read/update/delete is filtered by it, in SQL, never in
JavaScript), or access: shared (every grant-holding user sees every row).
Time Sheet Entry's entries table is owner/created_by; its projects
table is shared.
Optimistic concurrency. Every row carries _sasha_version (returned bylist/get), starting at 1 and incremented on each update. update/delete
require expectedVersion; a mismatch is APP_CONFLICT โ reload the row and
show the caller a clear message rather than silently overwriting a concurrent
edit (Time Sheet Entry's app.js does this for its hours-edit control).
Idempotency and receipts. Every mutating call requires an idempotency
key (Sasha.data.*'s options generate one automatically unless the caller
supplies one). The server records a (actor_id, operation, idempotency_key)
receipt in _sasha_idempotency before running the mutation; a retried call
with the same key and the same input hash replays the original published
result rather than writing twice, and survives a process restart (Milestone 2's
gate proves this explicitly โ see appRestart.integration.test.js). A key
reused with different input is APP_IDEMPOTENCY_MISMATCH.
The audit ledger. Every create/update/delete appends one row to the
per-app _sasha_audit table: table_name, record_id, operation
(create/update/delete), actor_id, source (ui/mcp/import/skill/automation), app_revision, action_id (when the mutation ran
through a declared action), before_json/after_json and created_at.
Three triggers make the table append-only (no UPDATE, no DELETE, noINSERT OR REPLACE smuggling a delete-then-insert past them). Milestone 2
writes this ledger; reading it back through any UI or API is Milestone 3/4
work (see the boundary below) โ there is no _sasha_audit reader today.
App Actions: declaration and execution
An App Action (actions/<action-id>.action.yaml, declared in APP.md underactions with file: and optional exposeToModel: true) compiles a boundedinputSchema/operation/resultSchema into exactly one governed data-service
call โ aggregate, list, get, create, update or delete โ never raw
SQL, and never a caller-selected table outside the app's own declared set. It
grants no authority beyond the table access the app already declared.
Visibility defaults to ['app'] (only the app's own UI can call it, throughSasha.actions.call(actionId, input, options)); exposeToModel: true adds'model', publishing it as the MCP tool app__<app>__<action> โ do this only
when the requested outcome genuinely needs Sasha to invoke it. Time Sheet
Entry's weekly-project-totals (an aggregate, read-only) is model-visible;
its log-hours (a mutating create) is app-only, exercising the "Sasha can
invoke its one exposed action but not its app-only one" boundary. An
action-driven update or delete is refused at compile time
(APP_ACTION_INVALID) in Milestone 2 โ the runtime has no way to supply the
caller's expectedVersion for an action-bound mutation, so it would always
race the record's own optimistic-concurrency check blind. Use the per-tableapp__<app>__<table>__update|delete tool, or Sasha.data.update/Sasha.data.delete from the app's own view, until Milestone 3 plumbsexpectedVersion through the action grammar.
Host confirmation: a model-invoked mutating action or per-table mutation
(never a direct UI call, which PRD ยง5.4 exempts) must be confirmed before it
runs, or the call fails APP_CONFIRMATION_REQUIRED (a standalone MCP client)
or returns a confirmation challenge (the Studio chat path โ see "Studio
chat-path confirmation" below).
Action-level where, and why it differs from the data grammar. An
action's declared operation.where (server/apps/appActions.js) is a list of{ field, operator, valueFrom } clauses, where valueFrom names a declaredinput.* property, not a literal. Its operator set iseq/neq/lt/lte/gt/gte/in/week-of โ the one operator the
governed data grammar above does not carry. week-of exists only at the
action layer: appActionService.js's translateOperation rewrites it, before
the call, into a gte/lt pair spanning the seven days from the caller's
ISO date (addDaysIso). Conversely, the action grammar has nocontains/starts-with โ the two substring operators Sasha.data.list/Sasha.data.aggregate support directly. An action author who needs a
substring match must use the data grammar directly from the app's own view,
not an action.
Model-visible vs. app-only MCP tools
Per app, per declared table, the platform generates six data tools โapp__<app>__<table>__list|get|aggregate|create|update|delete โ and every
one of them is model-visible (_meta.ui.visibility: ['model', 'app']) for
every declared table of every data app the caller holds use on. The three
read ops (list/get/aggregate) need no confirmation; the three mutating
ops (create/update/delete) require host confirmation before they run,
exactly like a mutating action. A declared App Action carries its own
visibility: it reaches tools/list for the model only withexposeToModel: true; without it, the action is app-only
(_meta.ui.visibility: ['app']) โ callable only from the app's own UI viaSasha.actions.call, never by the model. The open_app__<id> opener is
always model-visible. ['app']-only visibility therefore applies to
app-only actions alone, never to the table tools. A caller without a grant on
an app sees none of its tools at all โ the isolation gate โ independent of
visibility.
Studio chat-path confirmation (accepted Milestone 2 limit)
MCP defines host confirmation for a model-invoked mutation aselicitation/create โ a request the host answers, not the model. An
external MCP client (Claude Desktop, a third-party agent) gets exactly that:
the host intercepts the elicitation and the model never sees the
confirmation token.
The Studio chat session has no such host-side elicitation channel today. ItsconfirmMutation seam (server/mcp/server.js) instead returns a
non-error challengeResult (appMcpSurface.js) carrying the plaintextconfirmationToken in the tool result handed to the model, with an
instruction to ask the user via the AskUserQuestion tool and retry with the
same token if the user approves. Approval is therefore prompt-enforced,
not host-enforced: the model holds a live, single-use token that redeems a
real mutation, and nothing stops the model from calling the tool again with
that token without truly waiting for the user's answer. For the token's
lifetime (10 minutes; the confirmation store's TTL) and for the wider
session-token TTL (2 hours), the model effectively holds the user'sapps:invoke authority for anything the user could confirm by answering
"yes" to an AskUserQuestion prompt. The self-MCP token config file the
chat session's own tool calls can read is also 0600 file-permissioned but
readable by any shell command the model runs inside that same session.
This is an accepted Milestone 2 limit (owner ruling Q4, 2026-09-13), not an
oversight: routing Studio chat-path approval through an actual Studio UI
confirmation surface (so the human, not the model, redeems the token) is
tracked for a later milestone. Until then, treat a Studio chat session as
holding real mutation authority over every app-data table its user can use,
gated only by the model correctly relaying the AskUserQuestion prompt.
sasha:// resources
Each app additionally exposes read-only MCP resources describing its own data
shape and action catalogue, gated by the same apps:invoke + per-app grant:
sasha://apps/{appId}/tables
sasha://apps/{appId}/tables/{table}/rows{?q}
sasha://apps/{appId}/tables/{table}/rows/{id}
sasha://apps/{appId}/tables/{table}/aggregate{?q}
sasha://apps/{appId}/actions
The recovery view
GET /api/apps/:appId/recovery (gated by apps:invoke plus per-appmanage โ an app-level authority, never the platform admin/staff role the/access routes use) returns a schema-derived, bounded table/field
descriptor Studio's AppRecoveryPanel renders as a generic CRUD grid when an
app's own custom UI is broken. It is derived purely from the declaredtables policy and describeSchema's column record (required fromnotNull/defaultValue, readOnly from primaryKey/the declaredownerField) โ no new column shape, no SQL text, and no code path shared
with the app's own UI.
New error codes and their HTTP mapping
Beyond Milestone 1's closed set, M2 adds: APP_ACCESS_DENIED (403),APP_RECORD_NOT_FOUND (404), APP_CONFLICT (409), APP_IDEMPOTENCY_MISMATCH
(409), APP_FIELD_UNDECLARED (400), APP_SCHEMA_INCOMPATIBLE (503, though
this should never be reachable outside a corrupted install โ schema
compatibility is checked once per handle open), APP_MIGRATION_FAILED (503),APP_CONFIRMATION_REQUIRED (403) and APP_DATABASE_UNAVAILABLE (503,
retryable โ the one M2 error safe to retry automatically). APP_SCHEMA_INCOMPATIBLE
and APP_MIGRATION_FAILED share APP_DATABASE_UNAVAILABLE's 503 status โ
an app whose data cannot be opened is unavailable, not a bad request โ but
both are marked non-retryable: a schema mismatch or a failed migration will
not resolve itself on retry the way a transient database lock might.APP_NOT_FOUND andAPP_PATH_INVALID keep Milestone 1's hidden-404 treatment; every other mapped
code renders the safe {code, message, retryable, diagnosticId, details?}
envelope over its specific status.
Editing app source through MCP (Milestone 3a)
Six model-only MCP tools โ listAppFiles, readAppFile, writeAppFile, editAppFile, deleteAppFile, checkApp โ let an admin or staff user who holds manage on an app edit its package under <DOCS_ROOT>/apps/<id>/ (scope apps:write, which depends on apps:invoke; members never hold it). The Studio chat session token carries the same scopes, so chat and external clients behave identically. Writes are atomic (temp file + rename inside the package), audited to authorization_audit_log as app-source.changed with before/after content hashes only, and live immediately: the catalogue's package fingerprint changes, the next tools/list rebuilds the resource and revision.
A write is never refused for breaking validation. Instead every mutation and checkApp return status = { ok, revision, diagnostics }; each diagnostic is { file, line?, code, reason } using package-relative paths only. Diagnostics come from a non-enumerable authoringHint that the validators (appManifest.js, appActions.js, appSchema.js, appResource.js) attach to the closed AppContractError they already throw, read only by catalog.diagnose(). describeMigrationDrift() additionally compares the package's migration checksums with _sasha_migrations in the live database, so editing an already-applied migration is reported before the next data operation fails.
When the package fails to validate, the app has no open_app__<id> tool and its data tools are gone; a migration-drift diagnostic alone leaves the app listed until the next data operation fails; the six source tools stay because they take the app id as an argument. Limits: text extensions only (.md .html .css .js .sql .yaml .yml .json .txt .svg), 1 MiB per file, 64 files and 1 MiB per package, depth 4, APP.md cannot be deleted. Consequences: an edited bundled app matches no shipped tree hash, so the seeder preserves it and stops refreshing it; and an MCP grant never widens, so a connector approved before this release must be reconnected to gain apps:write.
Milestone 3/4 boundary (explicit)
None of the following exist yet: draft/publication workflow, revision
transitions (transition-one/transition-many are declared in the operation
registry at availableFrom: 4 but return APP_OPERATION_UNAVAILABLE today),
CSV import/export, or any reader for _sasha_audit/historical records. Do not
imply any of these ship in M2 when authoring or reviewing an app.
M3a (this surface, delivered) ships the MCP app-source write tools above:
live on save, no draft, every validation failure returned as file/line/reason
diagnostics. Drafts, safe preview, schema diff and transactional publication
(M3b) remain deferred per the 2026-09-14 ruling.
Operator notes โ Milestone 2 behaviour changes
Members see an empty Apps sidebar after upgrading. M2's discovery
registrar seeds manage only to admin- and staff-role users at an app's
first discovery (owner ruling Q3), and M2 ships no grant UI (owner ruling
Q2). A member (or any user created after that first discovery) sees no apps
at all until an admin or staff user grants use explicitly. Restore access
for one app/user in one line:
curl -X PUT https://<studio-host>/api/apps/<app-id>/access/<user-id> \
-H "Authorization: Bearer <admin-or-staff-session-token>" \
-H "Content-Type: application/json" \
-d '{"access":"use"}'
access must be "use" or "manage". DELETE the same URL to revoke.GET /api/apps/:appId/access (also admin/staff-only) lists current grants
for an app.
APP_DATA_ROOT must not be inside DOCS_ROOT. The server refuses to
boot if it is: assertAppDataRootServable checks containment (lexically,
then again after resolving symlinks, to defeat a symlink pointing back into
the docs root) before ever creating the directory. Every app's operational
SQLite database would otherwise be a downloadable static file under the
served docs tree. If APP_DATA_ROOT is unset, it defaults to an apps/
sibling of the core database file (<dirname(DB_PATH)>/apps) โ outsideDOCS_ROOT in every shipped configuration, but an operator who overrides
either path must keep this invariant.
Sandbox setup
Production requires two different HTTPS hostnames on the same application
listener, valid DNS and TLS for both, and explicit origins:
SASHA_PUBLIC_ORIGIN=https://studio.example.com
SASHA_APP_SANDBOX_ORIGIN=https://views.example.com
Use absolute origins without trailing slash, path, query, fragment, credentials
or wildcard. Different ports on one hostname do not isolate cookies and are
rejected. Configure host-only Studio cookies; do not share a parent-domain
cookie with the sandbox. MCP_PUBLIC_ORIGIN configures MCP/OAuth, not this boundary.
The reverse proxy must preserve Host, replace untrusted forwarded headers, and
supply the exact configured protocol. Production trusts one proxy hop, so the
application listener must be reachable only through that ingress. Sandbox-host
requests terminate before Studio middleware: only /app-sandbox and its hashed
proxy script are served, with no cookies or reflected CORS. Other sandbox paths,
including /api, /mcp, / and Studio assets, return 404. Studio refuses sandbox
paths. See the detailed sandbox guide for header rules.
Local development uses Studio http://localhost:3007 and sandboxhttp://127.0.0.1:3007 on the Node listener. For another local port set both
origins explicitly. UI-only Vite does not serve the sandbox. Run the normal
local server, ensure <DOCS_ROOT>/apps contains validated packages, then select
an app in the sidebar. Do not activate a source change without the user's
publication approval; M1 has no automated draft/publication service.
The outer iframe explicitly uses referrerPolicy="origin": the proxy receives
only the Studio origin, with no path or query, despite Studio's global HelmetReferrer-Policy: no-referrer. This origin is required for the proxy handshake.
The outer iframe uses allow-scripts allow-same-origin allow-forms. Native form
submit events require this on every ancestor; CSP form-action 'none' still
blocks any actual form navigation. The proxy creates one
inner iframe using allow-scripts allow-same-origin allow-forms, empty device
permissions and an opaque data: document. Even with allow-same-origin, that
document cannot read Studio or proxy cookies, storage or DOM. It cannot navigate
top, create popups, or navigate itself onto the proxy origin. Both source window
and origin are pinned; the proxy validates standard schemas and correlated
results. The opaque target needs postMessage target *, but only its pinned
WindowProxy accepts that path. App authors use the facade, not raw messages.
CSP starts at default-src 'none'. No external connect/resource/frame/base
domains or device permissions are accepted. The outer policy allows the local
proxy script and the inline script/style inheritance ceiling needed by a data
document; the assembled resource adds exact script/style hashes. Both policies
apply. No unsafe-eval or external URL is allowed; outer frame-src data: blocks
navigation onto the proxy hostname. This is browser isolation, not a JavaScript
convention. M1 still deliberately permits only one live view.
Browser facade and lifecycle
The maintained @modelcontextprotocol/ext-apps 1.7.5 App and AppBridge use
protocol 2026-01-26. The generated convenience API waits for initialization,
receives presentation context, reports size, and acknowledges resource teardown.
Host context supplies theme/styles, locale, timezone, dimensions, display mode,
touch/hover and safe-area values; none grant identity or authorization.
await window.Sasha.ready(); // Shared initialization promise
const offStream = Sasha.chat.onStream((fullText, done) => {
output.textContent = fullText; // Replace, not append; never interpret HTML
});
const offResult = Sasha.chat.onResult(fullText => { /* optional final observer */ });
const text = await Sasha.chat.execute('Draft a summary', { newSession: true });
const files = await Sasha.files.upload(fileInput.files);
// Milestone 2: governed data and declarative actions, gated by the app's
// own declared table access โ never a new authority the app didn't declare.
const { rows } = await Sasha.data.list('entries', { where: [{ field: 'date', operator: 'gte', value: '2026-09-01' }] });
await Sasha.data.update('entries', rows[0].id, { hours: 2 }, { expectedVersion: rows[0]._sasha_version });
const { totalHours } = await Sasha.actions.call('weekly-project-totals', { weekStart: '2026-09-08' });
offStream();
offResult();
connected, capabilities and hostContext expose readiness and negotiated
availability. Chat and upload dispatch only through app-only toolssasha__chat_execute and sasha__files_upload; neither appears in model tools.
The host binds the active app/revision and declared capability, creates the chat
trace, ignores other turns, and permits one in-flight operation for chat/upload
(data operations run through their own small bounded concurrency pool โ see
above โ so a data read never blocks on an in-flight chat turn). Progress
carries full accumulated text. The successful result's structuredContent
carries UI values; content is concise or empty and _meta contains only
safe runtime metadata. _meta is not a security boundary.
Upload accepts up to 10 supported documents and 25 MiB total. It returns an array
of safe descriptors such as {original:'brief.txt', converted:'brief.md'}, not a
JSON string or an absolute path. Require a nonempty array and usable document
names before constructing @brief.md references. The Report Builder example
shows the production descriptor shape and refuses empty/malformed results.
Failures have {code, message, retryable, diagnosticId, details?} with bounded
safe fields. Milestone 1's codes (APP_NOT_READY, APP_CAPABILITY_DENIED,APP_OPERATION_UNAVAILABLE, APP_INPUT_INVALID, APP_RESOURCE_LIMIT,APP_BUSY, APP_CHAT_UNAVAILABLE, APP_CHAT_FAILED, APP_UPLOAD_FAILED,APP_TIMEOUT, APP_CANCELLED, APP_REVISION_UNAVAILABLE) still apply;
Milestone 2 adds the data/action codes listed in the previous section.Sasha.actions.call() now executes: it no longer unconditionally returnsAPP_ACTION_UNAVAILABLE (that code is still returned for an unknown or
app-only action id called from outside the app itself, or one no active
grant reaches). Display safe messages and preserve diagnostic IDs; do not
expose raw service errors. The facade preserves valid host diagnostic IDs,
generates a UUID for local failures, and derives messages/retryability from
the closed error registry. Unknown transport failures are sanitised; protocol
timeouts become APP_TIMEOUT.
Initialization times out after 15 seconds; reported height clamps to 240โ1200 px.
Close/switch immediately stops new host operations, waits for teardown
acknowledgement or the two-second limit, then removes the frame. Late results are
ignored. This cancels the app invocation; it does not undo an upload already
accepted by the server or terminate the user's underlying chat session.
Themes, authoring and action boundary
theme: sasha-timesheet-v1 supplies host-aware fallback tokens matching the
captured time-sheet theme: warm #f4f4f0 canvas, white surfaces, #d4d0c8 borders,
navy #1a4a8a, near-black #1c1c1c, muted #6b6b6b, danger #b52020, success#1a6640, Georgia, Courier New and 4 px radii. Copy the canonicalapps/_shared/sasha-timesheet-v1.css into the package as theme.css; package
containment forbids referencing ../_shared. theme: none keeps only the
reset/accessibility baseline: remove the starter stylesheet link and replace
app presentation while retaining the same facade. Preserve labels, focus,
reduced-motion handling and responsive layout.
Sasha alone maintains app source in response to user requests. The
app-builder skill directs
inspection, deterministic-versus-AI classification, separate draft preparation,
validation, sandbox preview and explanation before explicit publication approval.
Arithmetic and filtering stay local; requested drafting/synthesis uses chat.
An action is declarative registry-backed business behavior, not a JavaScript
export. Up to 20 actions use bounded files and closed input/result schemas. The
supported schema subset requires object roots and closed nested objects, at most
50 properties in total per schema, nesting depth 6, and scalar enums of 1โ50
unique correctly typed values. Strings need maxLength at most 65,536 characters,
a bounded enum, or the supported date format (exactly 10 characters); other
formats are rejected. Arrays require items and maxItems at most 1,000.
Optional minimum bounds must not exceed maximum bounds. Numeric bounds must be
finite, and integer bounds/enum values must be safe integers. Unknown keywords,
keywords for another type, malformed values and inconsistent required fields
are rejected. These were M1 descriptor-only checks; M2 executes the compiled
descriptor through the governed data service (see "App Actions: declaration
and execution" above). Descriptions are ordinary human text; operation kinds, fields and input
references are checked by explicit shape and vocabulary rules.
Schema property names use ASCII letters, digits, hyphens and underscores;
table/field selectors retain the ASCII identifier grammar. Reserved identity,
executable escape and prototype names are compared as whole names after folding
case and hyphen/underscore separators. userId, USER_ID and user-id are
reserved aliases; business names such as projectCode, factor andtransactionCount remain valid. Accepted names keep their original spelling,
so projectCode and project_code remain distinct properties. Unicode lookalikes
and invisible characters are rejected rather than normalized into identifiers.
The platform derives effects. IDs map injectively from kebab-case to MCP names:timesheets + submit-week becomes app__timesheets__submit_week; underscores
are invalid source IDs. Visibility defaults to ['app']; explicitexposeToModel: true compiles to ['model','app']. An exposeToModel: true
action is additionally published as the MCP tool app__<app>__<action>; an
app-only action stays reachable only through Sasha.actions.call() from the
app's own UI. No source scanning, SQL, dynamic code, network, caller identity
or action-to-action escape hatch is accepted.
At concrete API friction Sasha files one safe suggestImprovement through its
authenticated MCP connection or the existing injected in-app inbox transport.
There is no browser feedback API or self-MCP token. Use kind, a 10โ300 character
summary, detail at most 2 KiB, safe generic toolName, justified severity, and
runtime-supplied diagnostic/runtime/protocol/operation metadata. Never include
records, uploaded files, source, credentials, absolute paths or sensitive app
IDs. Failure to record feedback does not block the user's work. Follow
PRD 24, not the retired draft feedback name.
Seeding and verification
Container startup seeds validated absent packages and _shared from the image.
Existing directories, including empty or edited packages, are preserved unless
their on-disk tree hash exactly matches a previously-shipped release recorded
in scripts/bundled-app-shipped-hashes.json (one array of tree hashes per
bundled app id) AND differs from the current bundled content's own tree hash
โ that one case is replaced under the seeding lock; a crash between thefs.rm of the old tree and the fs.rename of the new one is self-healed on
the next start (the destination is simply absent, so the next run treats it
as a fresh install). An on-disk tree already matching the current bundled
content is left untouched (no rm, no rename, no re-publish) and reported
separately as up to date, so an unmodified package is not churned on every
restart. _shared is never refreshed this way. Exact
known untouched old samples are moved into .retired-bundled-apps for recovery;
modified or suspicious samples are preserved with warnings. Checkout/image
deployed snapshots are read-only.
Publication uses staged copies plus rename under a cooperative writer lock.
Container startup uses its existing knowledge flock; standalone CLI seeding
uses .bundled-apps.lock. Those lock domains do not coordinate. Do not run them
simultaneously against one docs root or infer protection against unrelated
writers. A killed standalone seeder can leave an abandoned lock; subsequent
standalone calls fail closed. Safe owner-aware recovery is tracked, not automatic.
cd claudecodeui
node --env-file=.env.test node_modules/vitest/vitest.mjs run --project=unit-server --project=unit-client --project=integration --project=integration-process server/apps src/apps src/hooks/__tests__/useAppHostBridge.test.jsx src/components/__tests__/AppSidebar.test.jsx
npm run build
node scripts/run-playwright-e2e.js e2e/apps.spec.js
npm test
npm run build
npm run check:app-sandbox
npm run test:app-sandbox:browser
The raw MessageChannel peers test MCP listing/resources and real host lifecycle
without an SDK client on both ends. The browser gate uses the built Studio on
localhost and sandbox on 127.0.0.1 at one ephemeral port, temporary state and a
generated internal test account. It replaces only AI response/document conversion
boundaries with synthetic fixtures; the UI, auth, resource routes, facade, relay
and host remain real. Optional Studio font/icon CDN links are removed from the
offline test shell. Unexpected network fails the gate. No email, SMS or customer
notification is sent. These are local Chromium acceptance checks; they do not
claim compatibility with every external MCP host or verify production DNS/TLS.
Acceptance evidence โ 12 September 2026
The implementation-branch gate passed 345 Vitest files: 4,387 tests passed and
one pre-existing test skipped. The focused plan command passed 189 tests;
expanded protocol/host/UI regressions passed 278. The selected Studio browser
gate passed, the complete browser runner passed both tests, and the standalone
Chromium sandbox probe passed for normal and exactly 1 MiB resources.
The raw MCP resource test
reads all four resources twice and checks the future exposed-action contract.
The independent UI protocol test
proves initialization, progress, audience separation, context, resize and teardown.
The real Studio browser gate proves the three
converted chat/file flows, local time arithmetic, replacement presentation,
opaque-realm isolation and blocked malicious form navigation. It produces
time-sheet/theme screenshots and isolation-and-flows.json. To retain artifacts,
set SASHA_PLAYWRIGHT_ARTIFACT_DIR to an absolute local output directory when
running the contained browser command. The temporary server/account/data are
removed after the run; screenshots may show the expected no-provider warning
because the fixture deliberately configures no live AI service.
