Skip to main content

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.

PieceOwnerCarries
embeddings/<name>.jsAuthorThe use case: a config export plus GET and POST handlers.
app_embedding tablePlatformOne projection row per use case: validated config, a revision hash, and pump run state. Rebuilt from the folder on every deploy.
Workspace tablesAppThe 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 content exceeds 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[].metadata is always an object; headingPath (the prose profile) 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).

KeyMeaning
descriptionFree text shown in the admin Embeddings tab and the status listing.
chunkingnone (row = one embedding, the default), prose (markdown-aware recursive splitting with heading breadcrumbs), or code.
maxTokensChunk budget for prose/code: 16 to 8191, default 512. Not allowed under none.
overlapTokensTokens 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.
onEvent names (from emit()) that trigger a run; a single string is accepted.
cronA five-field cron schedule; desugars into an automation that triggers runs.
batchSizeRows requested per GET (1 to 1000, default 100).
timeoutSandbox wall-clock per handler invocation: 1000 to 300000 ms, default 30000.
revisionAuthor-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.embeddings on the handler bag. embed is always a function server-side and throws when the app type lacks the capability, so typeof embed === 'function' only tells an older server (no embed at 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>' } in informer.yaml refuses 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 like server/: each file must export both GET and POST, 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 GET and POST never collide with a server/ or webhooks/ file of the same path: pump handlers persist under their own methods, so embeddings/articles.js beside server/articles/index.js is 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 a server/ or webhooks/ file handles the same method and path, otherwise with an error that the pump contract is GET and POST only.
  • 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.