Embeddings
Apps can maintain vector embeddings over their own data declaratively. The
app ships an embeddings/ folder with one file per use case; the platform
acts as an embedding pump that asks the app what is pending, chunks and
embeds the content in billed batches, and hands the vectors back for the app
to store in its own workspace tables.
Concept
The design inverts the usual indexing service: the platform holds no copy of the corpus and no progress ledger. The app's own storage is the ledger.
| Piece | Owner | Carries |
|---|---|---|
embeddings/<name>.js | Author | The use case: a config export plus GET and POST handlers. |
app_embedding table | Platform | One projection row per use case: validated config, a revision hash, and pump run state. Rebuilt from the folder on every deploy. |
| Workspace tables | App | The vectors themselves, in whatever schema the app's migrations define. Cleanup is the app's own foreign keys. |
A use case file exports both halves of the pump contract:
// embeddings/tickets.js
export const config = { chunking: 'none', on: ['ticket.created'], batchSize: 100, revision: 1 };
// GET: return the rows that still need embedding. The query IS the watermark.
export async function GET({ query, batch }) {
return await query(`
SELECT t.id, t.subject || ' ' || t.body AS content
FROM tickets t
LEFT JOIN ticket_embeddings e ON e.ticket_id = t.id AND e.seq = 0
WHERE e.ticket_id IS NULL OR e.embedded_at < t.updated_at OR e.revision <> $2
ORDER BY t.id
LIMIT $1
`, [batch.limit, batch.revision]);
}
// POST: store the embedded results wherever the app wants.
export async function POST({ query, batch }) {
for (const doc of batch.docs) {
await query('DELETE FROM ticket_embeddings WHERE ticket_id = $1', [doc.id]);
for (const c of doc.chunks) {
await query(
'INSERT INTO ticket_embeddings (ticket_id, seq, embedding, content, revision) VALUES ($1, $2, $3::vector, $4, $5)',
[doc.id, c.seq, JSON.stringify(c.embedding), c.content, batch.revision]
);
}
}
return { stored: batch.docs.length };
}
Because GET anti-joins the app's own embedding table, the pump is
idempotent and crash tolerant by construction: a run that dies mid-way
simply finds the same rows pending next time, and the app's POST upserts.
The pump contract
Each run drains the pending set in a loop: GET the next batch, chunk each
row's content by the configured profile, embed all chunks in batched
provider calls, then POST the results. The loop stops when GET returns
[] or fewer rows than batchSize (return a full page while work remains),
when a batch yields no embeddable rows, or after 20 batches. drained: false
in the run result means the 20-batch cap was hit and the use case re-poked
itself for the next sweep. A batch whose rows were all tombstoned stops (the
app has to exclude them).
GET receives batch: { limit, revision } and must return an array of at
most limit rows shaped { id, content, metadata? }. Returning no body at
all is an authoring error (a missing return must not read as a drained
corpus). Returning more rows than limit is a contract violation: the run
fails with a 422 and parks the use case instead of embedding the whole table
in one run. The usual cause is a pending-set query without LIMIT $1.
A document is reported in failures and skipped on later runs, rather than
chunked, when:
- its
contentexceeds 200,000 characters (content_too_large), - it produces no chunks at all because it is empty or whitespace-only
(
no_content), or - it chunks into more than 2,000 chunks (
too_many_chunks).
All three are deterministic for that content, and tokenizing is synchronous
work that would otherwise stall the server. The 200,000-character ceiling
applies to the whole document before chunking, so it never concerns an
article, a ticket or a product row; it concerns a whole PDF, a contract or a
transcript, which GET should return as several rows, one per chapter or
heading. Splitting the document is also the fix for too_many_chunks.
Raising maxTokens fixes it too, but maxTokens is part of the revision, so
the change re-embeds, and re-bills, every document the use case has stored.
POST may be called more than once per GET batch: results are stored in
slices of at most 2,000 chunks, the same limit as too_many_chunks, so one
document always fits in one slice. A batch of large documents therefore
cannot exceed the sandbox's memory, and already-embedded vectors are not lost
to a single failed call. Each call carries the same revision;
failures rides the first call of a batch. Handlers already had to be
idempotent (the pump is at-least-once), so a correct upsert needs no change.
POST receives batch: { revision, docs, failures }:
docs:[{ id, metadata, chunks: [{ seq, content, metadata, embedding }] }], grouped per document so delete-and-replace is natural.failures:[{ id, error, code, permanent, skipped? }]. Deterministic failures (content over the model input cap, chunker errors) are reported here and tombstoned platform-side by content hash, so an unembeddable document is attempted once per content and configuration even if the app ignores this array (the platform keeps the most recent 1,000 tombstones per use case; older ones are retried). Tombstoned rows are re-reported on every run (skipped: true) so the app can stamp and exclude them.chunks[].metadatais always an object;headingPath(theproseprofile) is its only key today.
The revision string couples the author's config (chunking profile, token
budgets, config.revision) to the resolved embedding model. Store it beside
each vector and include <> $revision in the GET query: bumping
config.revision, changing the chunking, or repointing the platform
embedding model all surface as pending re-embed work automatically.
Config
config is optional; a file that exports only GET and POST deploys as
{ chunking: 'none', batchSize: 100 } with no triggers, so it runs only on
deploy and on a manual run. The token budgets are not part of that default —
under none they are refused rather than applied (see the table below).
| Key | Meaning |
|---|---|
description | Free text shown in the admin Embeddings tab and the status listing. |
chunking | none (row = one embedding, the default), prose (markdown-aware recursive splitting with heading breadcrumbs), or code. |
maxTokens | Chunk budget for prose/code: 16 to 8191, default 512. Not allowed under none. |
overlapTokens | Tokens carried from each chunk into the next for prose/code: default min(64, maxTokens / 8), and at most half of maxTokens (checked at deploy). Above half, each chunk advances by less than it repeats and the tokens billed grow superlinearly in the ratio. Not allowed under none. |
on | Event names (from emit()) that trigger a run; a single string is accepted. |
cron | A five-field cron schedule; desugars into an automation that triggers runs. |
batchSize | Rows requested per GET (1 to 1000, default 100). |
timeout | Sandbox wall-clock per handler invocation: 1000 to 300000 ms, default 30000. |
revision | Author-bumped value that forces a corpus-wide re-embed. |
Unknown keys fail the deploy. The schema is deliberately strict because the
config is persisted onto the app's route table and its embedding projection;
nothing unexpected may ride through. The config must be a self-contained
literal on the export const: the deploy scanner evaluates it with none of
the file's imports in scope.
Every trigger converges on the same mechanism: deploy pokes each use case
(initial backfill), matching emit() events poke, cron automations poke, and
the platform sweep runs pending use cases as the app owner. A manual run
claims the same lease directly. Runs take a single-flight lease per use
case, so concurrent triggers coalesce instead of double-embedding.
A failed run is retried or parked by cause. A provider error the SDK marks retryable, a handler timeout, a host-side sandbox failure, or an unknown error re-pokes the use case with a doubling delay (20 s, 40 s, 80 s, 160 s) and parks it on the fifth consecutive failure. A non-retryable provider error, an exhausted compute budget, missing handlers, or a contract violation parks it at once. A parked use case keeps its error on the status row; the next trigger (event, cron, deploy, or a manual run) gives it one more attempt, and a successful run resets the attempt count. A trigger that arrives while a run is in progress is never lost to that run's backoff or park.
Workspace and pgvector
Apps with the embeddings capability get the vector extension provisioned in
their workspace database where the server may create extensions (otherwise
the deploy log names the cause and a migration using the vector type
fails), and both the app role and its read-only workbench role can resolve
its type and operators. Migrations can declare real vector columns:
CREATE TABLE ticket_embeddings (
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
seq INTEGER NOT NULL DEFAULT 0,
embedding vector(1536) NOT NULL,
content TEXT NOT NULL,
revision TEXT NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (ticket_id, seq)
);
Open that migration with -- requires: embeddings so it is skipped, not
failed, on an install without pgvector (see Capability-Gated
Migrations); it applies on
the first deploy after the extension is there.
On such an install the deploy also creates the app's use cases parked,
with pgvector is not installed in the workspace database as their
lastError, and does not poke them. Nothing can be stored without the
extension, so a pump run there would pay a provider for vectors the app's
POST is about to reject with relation ... does not exist — and that
failure looks retryable, which buys five paid re-embeds of the same batch
before parking with an error that never names the real cause. The next
deploy after pgvector arrives applies the migration and starts the pump
normally.
Search is plain SQL in the app's own handlers. Every handler bag (server/,
webhooks/, tools/, mcp/) exposes embed(name, text) for query-time
vectors. name must be a declared use case (unknown names throw); all use
cases embed through the platform's informer-embedding alias, resolved to its
canonical model.
embed() returns { embedding, revision }, and revision is the same value
the pump stamps beside each stored vector. Filter on it as well as ordering
by distance. Resolving through the same alias only keeps a query in the
corpus's vector space while the alias has not moved and the pump has caught
up; when an admin repoints the embedding model, every new query vector is in
the new space while the stored rows are still in the old one, and an
unfiltered ORDER BY embedding <=> $1 silently returns near-random
neighbours. Comparing revisions turns that into an empty result set — a
visibly wrong answer instead of an invisibly wrong one — until the pump
finishes re-embedding under the new revision.
embed() is for query-time vectors, so it is bounded: at most 100,000
characters per call and 100 calls per handler invocation, and it is refused
when the app's compute budget is exhausted. Embedding a corpus belongs in an
embeddings/ use case, where the work is batched and billed per slice:
// server/search.js
export async function POST({ query, embed, request }) {
const { embedding, revision } = await embed('tickets', request.body.q);
return await query(`
SELECT t.*, 1 - (e.embedding <=> $1::vector) AS score
FROM ticket_embeddings e JOIN tickets t ON t.id = e.ticket_id
WHERE e.revision = $2
ORDER BY e.embedding <=> $1::vector
LIMIT 20
`, [JSON.stringify(embedding), revision]);
}
Embedding calls are billed to the app, one usage entry per batch of up to 128 chunks regardless of how many provider requests run underneath, and pump compute is metered per handler invocation against the app's compute budget.
Routes
GET /apps/{id}/embeddings
Returns { items: [...], modelError }. modelError is non-null when the
server cannot resolve an embedding model at all, in which case every run
fails until one is configured. Each item is one use case with its pump status:
declared config (description, chunking, on, cron, batchSize),
revision (config plus resolved model; null when no embedding model
resolves) and configRevision (the config-only half), running, pending
(a poke is queued) with nextRunAt (in the future while a retry backs off)
and attempts (consecutive failed runs), tombstoned (count of permanently
failed documents), lastRunAt, lastError, and the last run's lastFailed
and lastSkipped document counts. Tombstone content hashes stay server side.
Permission: read access to the App (the lookup pre). The listing is status metadata, matching the dependencies listing.
POST /apps/{id}/embeddings/{name}/_run
Runs one use case to drain immediately: claims the single-flight lease and
executes the pump loop, returning
{ status: 'ok', useCase, processed, failed, skipped, blocked, batches, drained }.
blocked is the number of rows in a batch that stopped the run because none of
them could be embedded: rows tombstoned by an earlier run, and rows that failed
permanently in this one (content_too_large, no_content, too_many_chunks).
It is 0 when no batch stopped the run. Unlike a run that hit the batch cap, a
blocked run does not re-poke itself, so the use case stays put until the app's
GET excludes those rows. Returns
{ status: 'already_running', useCase } when a fresh lease is held; a lease
older than 30 minutes (a crashed run) is reclaimed.
Errors: 402 when the app's compute budget is exhausted, 404 when the handlers
are not deployed, 422 when GET or POST break the contract (including an
error status the handler itself returned). Any 4xx parks the use case with
the reason as lastError. A 5xx (502 when the handler timed out or threw,
503 when no embedding model resolves, 500 for a provider error) means the
run failed after claiming the lease; whether it re-pokes with backoff or
parks is decided by cause, exactly as described above — a non-retryable
provider error surfaces as a 500 and parks. The sweep records the outcome the
same way.
Permission: permission.app.write. Running the pump triggers billed
provider calls, so the gate is a spend control, not just a data guard. The
platform sweep dispatches this route as the resolved app owner.
POST /apps/{id}/embeddings/{name}/_embed
Embeds one text through a use case's model and answers
{ embedding, revision }, the same pair a deployed handler's
embed(name, text) returns, from the same host function: the use-case
scoping, the revision, and the billing cannot drift between the two. The
Vite plugin's dev mirror calls this route when a project opts into
embeddings (see below), so a search handler can run under npm run dev
against the corpus the pump built on the server.
The payload is { text }. Text is bounded the way embed() is, at 100,000
characters; over that, or blank, answers 400. The text is embedded exactly
as sent, whitespace included, because that is what a deployed embed()
does with the same string — the route never trims on your behalf. 402 when
the app's compute budget is exhausted, 404 for an unknown app or use case,
502 when the embedding provider itself fails.
Permission: permission.app.write, checked before the use-case lookup
so names are not enumerable to a user with read but not write access. Every
call is a billed provider call, so the gate is the same spend control as
_run.
Availability and compatibility
The feature exists only on Informer releases that ship it; the folder,
embed(), and the status/run routes simply do not exist on older ones. An
app that must run on both detects the feature instead of assuming it:
- In handler code:
platform.capabilities.embeddingson the handler bag.embedis always a function server-side and throws when the app type lacks the capability, sotypeof embed === 'function'only tells an older server (noembedat all) from a newer one; check the capability flag to know whether a call will succeed. - In the browser:
window.__INFORMER__.platform.capabilities.embeddings. - As a floor:
requires: { informer: '>=<version>' }ininformer.yamlrefuses the deploy on an older server with a clear message, for an app that cannot work without the feature. See the manifest guide.
Locally, the Vite plugin's dev mirror reports embeddings: false: the pump
and embed() need a real Informer, so an app sees in development exactly
what it sees on an install without the feature, and its embed() throws a
written explanation rather than being undefined.
A project whose purpose is vector search can opt in with
informer({ mock: { platform: { capabilities: { embeddings: true } } } }).
The descriptor then reports embeddings: true to the page and to handler
code alike, and the dev bag's embed() posts to the _embed route on the
configured server.
It addresses the app by informer.id in package.json. informer-init
generates that id locally and your first deploy creates the app under it,
so the id is already there for a scaffolded project; what the call needs is
that this server has had a deploy of it, since the use case and its
corpus live server-side. If it has not, embed() says so with a 404 naming
the app and the use case rather than failing deeper in your own SQL. Each
call is billed to the app, the same bill the deploy-and-click loop already
pays. The pump itself still never runs locally.
Deploy behavior
- The
embeddings/folder is scanned likeserver/: each file must export bothGETandPOST, files must be top level (one per use case), and the folder is gated by the app type's embeddings capability. It is a server-side folder: nothing in it is served to browsers, and any entry that is not a handler is reported as a deploy warning. Apps that kept assets there before this release must move them. - Projection rows reconcile per deploy: config and revision are rebuilt, pump run state on surviving rows is preserved, and removed files drop their row. The app's own vector tables are never touched.
- A use case's
GETandPOSTnever collide with aserver/orwebhooks/file of the same path: pump handlers persist under their own methods, soembeddings/articles.jsbesideserver/articles/index.jsis the expected shape. Name a use case for the table it indexes. Any other export (PUT,PATCH,DELETE) fails the deploy: with an error naming both files when aserver/orwebhooks/file handles the same method and path, otherwise with an error that the pump contract isGETandPOSTonly. - Handlers are never dispatchable through the app's own API surface or webhooks, and never appear in the app's OpenAPI contract. Only the pump invokes them.