RAGs (knowledge collections)
A RAG (Retrieval-Augmented Generation collection) is a named, tenant-managed bundle of markdown documents your app pulls into a prompt at runtime — launch hooks, brand voice, customer profiles, FAQ snippets, anything your agent needs as context that the tenant edits without redeploying your app.
Apps don’t see RAG IDs directly. They declare slots in the
manifest; the tenant binds each slot to one of their RAGs at
activation; the platform resolves slot → rag_id on every call.
The pattern shines whenever content that’s app-private today wants
to be tenant-shared tomorrow: the same “Launch 2026” hooks can feed
your press-planner, your office-assistant’s email drafts, and your
social-manager — all pointing the same launch_corpus slot at the
same RAG.
When to reach for a RAG (vs. alternatives)
Section titled “When to reach for a RAG (vs. alternatives)”| Need | Use |
|---|---|
| Personality + tone rules of the agent | agent.system_prompt in the manifest |
| Static config baked at build time | env vars |
| Tenant-tunable scalars (digest_time, language) | manifest install_settings |
| App-private structured state (cursors, kv settings) | ctx.kv / bridge.kv |
| Tenant-curated textual content the app reads | RAG slot |
| Binary artifacts (PDFs, images, generated reports) | ctx.files / linkworld_files |
Manifest declaration
Section titled “Manifest declaration”apiVersion: linkworld.ai/v2app_id: press-plannerversion: 0.2.0
rag_slots: - name: launch_corpus description: | Hooks, bio, competitive framing for the current launch. Pulled into pitch-mail drafts. Without this the agent falls back to a generic message. required: true scope: read # default; "read_write" for ingest apps suggested_tags: # hint shown in the tenant's RAG editor - hook_a - hook_b - hook_c - bio - proof
- name: outlet_research description: "Optional: per-journalist research dossiers" required: false scope: readField rules:
| Field | Required | Notes |
|---|---|---|
name | yes | ^[a-z][a-z0-9_]{0,31}$ — slot key apps pass to rag_query. Unique within the manifest. |
description | yes | 1-280 chars. Shown in the activation consent dialog. |
required | no | Default false. Required slots block activation unless bound. |
scope | no | "read" (default) or "read_write". Only read_write bindings can call rag_upsert_document / rag_delete_document. |
suggested_tags | no | Up to 20 tags, max 32 chars each. Hint only — tenants can tag freely. |
Up to 10 slots per app. Validation runs both in the SDK (local) and on the platform at publish.
All tools live on the platform’s rag skill. Apps invoke them
through the standard tool bridge — same surface as every other
skill. The platform resolves slot → RAG using
linkworld_app_rag_bindings; apps never pass a rag_id.
rag_query
Section titled “rag_query”Retrieve up to top_k documents from a bound RAG. Filter by tags
(any-match) and/or full-text-search keyword match.
const res = await bridge.tools.call('rag_query', { slot: 'launch_corpus', tags: ['hook_c'], // optional, OR-semantics q: 'Mittelstand', // optional, FTS keyword filter top_k: 5, // default 5, max 20})// res.documents: Array of doc shape; res.rag_id, res.rag_namev1 retrieval is filter only — results are ordered by
updated_at DESC, not by FTS rank. Prefer precise tags for highest
signal; use q as a keyword fallback. v2 will add hybrid ranking
(FTS + embedding cosine) behind the same surface; the score
field on each doc is reserved for that.
rag_list_documents
Section titled “rag_list_documents”Same shape as rag_query minus FTS, paginated. Used by app UIs
that need to enumerate the corpus.
res = await ctx.tools.call( "rag_list_documents", slot="launch_corpus", tag="hook_c", # optional single-tag filter limit=100, # default 100, max 200 offset=0,)rag_get_document
Section titled “rag_get_document”Single doc by ID or by (slot, key) tuple.
res = await ctx.tools.call( "rag_get_document", slot="launch_corpus", key="hook_c", # or id="<uuid>")# res.document.body has the full markdownrag_upsert_document (scope: read_write)
Section titled “rag_upsert_document (scope: read_write)”Create or update a document. Either set body inline or pass a
source_file_id (a linkworld_assistant_artifacts ID) to have the
platform extract the text server-side and store the snapshot.
# Inline bodyawait ctx.tools.call("rag_upsert_document", slot="outlet_research", key="pacher-article-2026-04", title="Pacher — Brutkasten Apr 2026 KI-Übersicht", body="...markdown...", tags=["pacher", "april"])
# From a workspace file (PDF, markdown, HTML, JSON, plaintext)await ctx.tools.call("rag_upsert_document", slot="outlet_research", key="pacher-article-2026-04", title="Pacher — Brutkasten Apr 2026 KI-Übersicht", source_file_id="<artifact_id>", tags=["pacher", "april"])Read-scope bindings get error: "scope_denied: ...".
rag_delete_document (scope: read_write)
Section titled “rag_delete_document (scope: read_write)”Hard delete by ID or (slot, key). v1 keeps it simple — bring a
versioning layer in your app if you need history.
res = await ctx.tools.call( "rag_delete_document", slot="outlet_research", key="stale-article",)# res.deleted, res.deleted_countSlot resolution semantics
Section titled “Slot resolution semantics”Every tool call resolves (tenant_id, app_id, slot) to one rag_id
via the binding row. Failure modes apps should handle:
| Error string | Meaning | Fix |
|---|---|---|
rag_unbound: ... | Manifest declares the slot but the user never bound it (or unbound it later). | Tell the user to open the app’s settings; platform UI also reflects this. |
rag_orphaned_binding: ... | The binding exists but the RAG was deleted underneath it. | User must rebind in /user/knowledge → app settings. |
scope_denied: ... | App requested a write tool on a read-scope binding. | Manifest needs scope: read_write on the slot, then re-activation. |
fts_unavailable: ... | Backend driver missing the FTS path. Operational issue, not your code. | Report; meanwhile fall back to tag-only filtering. |
Apps cannot enumerate RAGs they aren’t bound to. Brute-forcing
slot names returns rag_unbound with no info leak.
Workspace-file ingestion
Section titled “Workspace-file ingestion”A RAG document can be sourced from a tenant’s workspace file
(linkworld_assistant_artifacts) instead of typed inline. The
mental model is source + cache:
- The artifact stays the canonical source — whatever the tenant sees in the Files tab IS the file.
- The RAG doc’s
bodyis a cached extracted-text snapshot bound to asource_snapshot_attimestamp. - The link is one-way: edits in the workspace file don’t auto-flow into the RAG cache; the tenant clicks “Aktualisieren” in the doc editor to re-extract.
Why a cache instead of pure-reference: FTS index needs the text on the row, PDF extraction is non-deterministic across library versions (pinning gives stable retrieval), and quota math (“how big is this RAG?”) must be one DB query.
Supported source types (extraction at upsert time):
| Content type | Extractor | Notes |
|---|---|---|
text/markdown, text/plain | raw text | UTF-8 |
text/html | tags stripped, entities decoded | minimal — good enough for snippet retrieval |
application/json | pretty-printed | preserves Unicode |
application/pdf | pypdf | text-only; layout discarded |
Other content types reject with unsupported_content_type — your
app can either pre-extract and pass body directly, or surface the
error to the user.
Source freshness in responses: rag_query /
rag_list_documents / rag_get_document decorate workspace-file
docs with a source_status field:
| Value | Meaning |
|---|---|
fresh | Cache snapshot ≥ source’s updated_at — content is current. |
stale | Source has been modified after the snapshot. UI prompts a refresh; apps can surface this in their own UI too. |
orphaned | Source file was deleted; the cache body persists as if it were an inline doc. |
null | Doc is source_type='inline' or 'app_generated' — nothing to compare. |
Activation flow
Section titled “Activation flow”When your manifest declares rag_slots, the activation consent
dialog gets a “Knowledge-Bindings” section after the scope grants.
Each slot renders as a row: pick an existing RAG from the dropdown,
or inline-create a new one with a name + optional description.
Required slots block activation. Optional slots can stay unbound —
the app will get rag_unbound at runtime for those slots, which is
expected.
Tenants manage their RAGs in /user/knowledge — a full CRUD UI
with markdown editing, tag filtering, and a workspace-file picker.
Bindings are visible per RAG (“Eingebunden in: press-planner ·
launch_corpus”) so the tenant always knows which apps are reading
which content.
Post-install rebinding: when your app’s manifest declares
chrome.panel (the right-side panel with a settings tab), the
tenant gets a “Knowledge” sub-tab inside Settings that lists each
slot with its current binding and a picker to change it — same UX
as the consent dialog, no re-activation needed. Optional slots can
be unbound from there; required slots can be re-pointed but never
left empty. The endpoint behind it is
PATCH /api/apps/{app_id}/rag_bindings, validated through the same
helpers as activation.
Limits
Section titled “Limits”| Resource | Soft cap | Hard cap |
|---|---|---|
| RAGs per tenant | 50 | 200 |
| Documents per RAG | 500 | 5000 |
| Body size per doc | 256 KB (enforced) | 256 KB |
| Total bytes per tenant | 50 MB | 500 MB |
| Slots per manifest | — | 10 |
The 256 KB body cap is enforced at the DB level via a CHECK
constraint — passing body over the limit errors at upsert. Pre-
extracted text from large PDFs typically lands well under the cap
(PDFs that extract to >256 KB are rare for the use cases RAGs
target).
Architecture deep-dive
Section titled “Architecture deep-dive”For the design rationale (why slots not RAG IDs, why source+cache,
how scaling looks at 10k users), read the in-repo RFC:
docs/architecture/rags_platform.md.