Artifacts¶
An artifact is a thing the assistant made that lives beside the chat instead of inside it: a study guide, a handout, a practice quiz, a deck of flashcards, a slide deck to present, all of them things the person will keep and come back to.
It opens in the conversation panel the moment the model starts writing it and fills in as the model writes, keeps every version, can be edited by hand, renamed, restored, printed, exported and reopened from a library, and never gets pasted back into the reply.
This doc covers the chassis and its four kinds, document, quiz, flashcards and slides; the follow-ups at the end list what later items add.
The agent loop and its tool registry are in agent-retrieval.md; the typed API framework the routes use is in spa-and-api-framework.md.
The models¶
apps/main_app/models/artifacts.py holds four rows, plus the export row under "Export" below.
Artifact is the identity: a UUID id, the owner (SET_NULL, for the same reason Conversation.user is - chat data outlives the person), the conversation and origin_message it came from, the kind, the live title, and archived_at.
ArtifactVersion is one immutable snapshot: number (1-based, unique per artifact), source (assistant, restore or person), restored_from_number, the title at the time, the content JSON, the sources the version drew on, a change_summary, the assistant message that made it, and created_by.
ArtifactDraft is the owner's edit in progress, one per artifact (OneToOneField): the title and content being typed, the base_version the edit started from, and a revision counting the saves since; its modified timestamp is what the idle rule reads.
ArtifactRender is the drawn pages of one version, one per version (OneToOneField): the status (queued, running, ready, failed), the page_count once there are pages, an error_message when there are not, created_at, the queued_at the deadline counts from, and finished_at.
Versions are append-only and a restore is a new version copied from an older one, so the current version is the highest number and there is no current_version column.
A check constraint pins that a restore names its origin and nothing else does; another pins kind and source to their enums.
Reads go through Artifact.objects.owned_by(principal), which takes an EndUser and nothing else, so an anonymous caller is a type error rather than an empty result.
ArtifactService (services/artifacts/service.py) is the only writer: create, add_version, restore, rename, archive, link_to_message, and the draft operations under "Drafts" below.
Every write to an existing artifact takes a row lock on it (select_for_update through owned()), and every write audits itself (below).
The kind contract¶
services/artifacts/kinds/ has one module per kind and one dictionary entry each.
A kind supplies the Pydantic model that shapes ArtifactVersion.content, a to_html renderer, and a prompt projection; CONTENT_MODELS maps ArtifactKind to the model.
Every model carries kind as a required literal, and ArtifactContent is the discriminated union over the five, so the tool schema, the OpenAPI document and the generated TypeScript all narrow on kind.
The stored JSON keeps no kind: dump_artifact_content is the one writer and leaves it out, and parse_artifact_content(kind, raw) injects the row's kind column, so the column stays the one stored home of the fact, a payload whose own kind differs from the row's is refused by name, and no row is rewritten when a kind is added.
ArtifactContentInvalid names the fields that failed and never the value: a body is too large and too personal to echo into a log.
Every model mixes in HiddenTextRepr so its repr never carries text, which is one of the three measures that keep an artifact out of a Sentry event (test_artifact_sentry.py; the others are the repr=False fields on the loop's tool-call dataclasses and the DraftSaveRequest repr).
document (kinds/document.py) is DocumentBody, one body_html string capped at DOCUMENT_BODY_MAX_CHARS.
Its validator strips [S#] citation markers and runs the body through services/html_sanitize.py, the Python home of the assistant-HTML allowlist that the SPA's sanitize.ts mirrors; test_artifact_sanitizer.py pins the two allowlists equal.
Every HTML field of the other two kinds goes through the same clean_html_field (kinds/_fields.py).
quiz (kinds/quiz.py) is QuizBody: optional instructions HTML and one to fifty questions, each a union on type of multiple_choice (two to eight options, exactly one correct), multiple_select (two to eight options, at least one correct), true_false (answer) and short_answer (answer_text, self-graded when taken).
Every question has a stem, points from 1 to 100, and optional explanation, hint and source_ref; to_html(content, answer_key=) is the print form with or without the key marked.
flashcards (kinds/flashcards.py) is FlashcardsBody: one to two hundred cards of front and back HTML, up to ten tags of forty characters, an optional hint and source_ref; fronts must be distinct by their case-folded plain text, so a deck never asks the same thing twice.
to_tab_separated(content, title, target=) writes the Anki file (header lines #separator:tab, #html:true, #deck:<title>, #tags column:3, then front, back and space-joined tags per row) or the Quizlet file (plain text, one tab per row, no tags).
slides (kinds/slides.py) is SlidesBody: a theme chosen from six designed palettes (midnight, parchment, laboratory, slate, meadow, ink), and two to forty slides, each a union on layout.
A title slide has an eyebrow, title and subtitle and is the first slide and only the first; a section slide is a divider of title and subtitle; a bullets slide has a title, subtitle and one to six bullets; a two_column slide has a left and a right, each a heading and its bullets; a closing slide has a title, subtitle and up to three takeaways.
A title or bullets slide may carry one aside, a union on kind of a code card, a callout or a quote.
Bullets take the same inline allowlist the other kinds take; every other field is plain text, and every slide carries plain-text speaker notes.
The model picks the theme and the layouts; it never picks a colour or a font.
to_html is the handout the Word, HTML and Google Doc exports carry, one section per slide with its notes under it.
The PowerPoint file is a different thing entirely: exports/pptx.py draws every slide itself on a blank layout, and the deck's PDF is Impress's conversion of that file rather than of the handout, so the PDF is the slides and carries no notes.
The panel reads the deck as the drawn pages of that same file, which is "The drawn pages" below.
A question's or a card's source_ref is one source key, file:<id> or web:<url>, the form citations.source_key produces; the model writes a turn's [S#] ref there and the tool rewrites it to the key before the save, and a [S#] ref in stored content is a validation error.
artifact_prompt_text is what the model reads back as the active artifact: a document's HTML, or every other kind's compact JSON with kind included, because update_artifact takes the whole content back and a projection would hide the answers, hints, points, types, refs, speaker notes the model would otherwise have to reinvent.
The caps per kind are module constants (QUIZ_MAX_QUESTIONS, STEM_MAX_CHARS, FLASHCARDS_MAX_CARDS, FLASHCARD_SIDE_MAX_CHARS, SLIDES_MAX and the rest), and the SPA's form editors carry the same numbers so a person is told before the server would refuse.
Adding a kind is a new module with its kind literal, a member on ArtifactKind and the union, one entry in CONTENT_MODELS, a case in artifact_to_html and artifact_prompt_text, a delta shape in events.py and its counting arm in artifact_stream.py, and a migration that widens the kind check constraint, since the enum is pinned in the database as well as in the code.
It is also a line in the prompt's kind rule, the same line at the end of both tool descriptions, the list of kinds on load_artifact_tools, and the catalog blurb in artifact_context.py, or the model has the kind and no idea when to reach for it.
On the SPA it is a case in ArtifactBody and ArtifactEditor, an entry in KIND_LABELS, KIND_TRAITS, KIND_ICONS and EMPTY_BY_KIND, and a fixture in test/artifactFixtures.ts; assertNever and the Record<ArtifactKind, ...> types turn every one of those into a compile error until it is written.
The tools and the loop¶
services/agent/tools/artifacts.py holds create_artifact(title, content, source_refs?, change_summary?) and update_artifact(artifact_id, content, change_summary, title?, source_refs?), and neither of them is in the registry.
A change_summary describes what the artifact covers or what changed and never counts its questions, cards or slides, because the counter on the artifact is the one source of that number, and both field descriptions tell the model so.
Each one carries the whole four-kind content union in its argument schema, which is what the token table below counts, and both would ride on every model call of every turn if they were registered.
What the registry holds behind the cc_artifacts switch is load_artifact_tools (services/agent/tools/load_artifact_tools_tool.py), a bootstrap tool with no arguments, the same progressive disclosure the connector actions use (connectors-philosophy.md section 9).
load_artifact_tools sets AgentState.artifact_tools_loaded and returns one line: the two tools are available in the next response, and their descriptions say how to write one.
How to write an artifact is not in that result, because the two tool descriptions and the field descriptions on their schemas already hold every one of those rules, and the model reads them the moment it has the tools.
rebuild_tools_with_loaded_extensions (services/agent/tool_wiring.py) runs at the start of every iteration and, when the flag is set, appends both tools built with the principal, state.conversation_id and the run state, so the model can call either of them from its next response to the end of the turn.
That append sits above the early return the rebuild takes when no connector is loaded, which is what most turns do.
The rebuild is also the only place a bound artifact tool comes from: conversation_id and run_state are required keyword arguments of _ArtifactToolBase, so a tool that would save into no conversation and resolve no source_refs is a type error rather than a quiet half-write.
A call to create_artifact or update_artifact before the loader has run is a call to a tool the iteration does not carry, and three things answer it.
not_found_error (services/agent/tool_execution.py) tells the model that create_artifact is not loaded, that load_artifact_tools comes first and that it can call create_artifact in its next response, so it has a route back.
It says that only when the loader is one of the iteration's own tools: a turn without the cc_artifacts flag carries neither the pair nor the loader, so an artifact name there keeps the plain Tool '<name>' not found every other missing name gets, rather than being sent after a tool that is not there either.
absent_tool_display_args beside it is what the span and the ToolCallEvent show for that call, {"argument_names": [...]} and none of the values, because a tool that is not there vets nothing and an artifact body must never reach a span or Sentry's frame locals.
The loop's panel branch reads ctx.state.artifact_tools_loaded as well, so such a call opens no panel and the person sees the narration alone rather than a card that fails.
create_default_registry(principal, artifacts=True) adds load_artifact_tools and nothing else; the flag is a capability switch the chat service derives from cc_artifacts, not part of the identity.
The guest registry never has it.
The registry's exact-set test in tests/test_agent_core.py pins both the plain set and that one-tool difference.
Both artifact tools are identity-bound (they hold the principal) and run the ArtifactService write under sync_to_async.
The kind is the content.kind literal, not a separate argument, and each description ends with the one-line kind rule (questions to practise are a quiz, things to memorise are flashcards, a talk to give is slides, everything to keep and reread is a document); update_artifact refuses a content whose kind differs from the row's, naming both.
source_refs carries the turn's [S#] refs; the tool resolves them, together with every question's and card's source_ref, against the run's citation registry into Source rows stored on the version, rewrites the per-item refs to their source keys, and reports any ref it could not resolve in the result text.
A successful call returns ArtifactSaved under ToolResult.metadata["artifact"], which is how the loop learns an artifact was made without reading the body.
Three things in the loop (services/agent/agent_core.py) are artifact-specific.
On the first ToolCallDelta of an artifact tool call, and only once the turn has loaded the tools, the loop emits ArtifactStartEvent (tool call id, step, tool name) once per call, before the arguments have finished streaming, so the panel opens on a skeleton seconds before the row exists.
While the arguments stream, the loop appends each fragment to ArtifactArgumentBuffers (services/agent/artifact_stream.py) and emits ArtifactDeltaEvent whenever the partial parse has something new: the title once it has closed, then a DocumentDelta of the body characters that arrived, a QuizDelta counting the questions whose stem has closed, a FlashcardsDelta counting the cards whose front has, or a SlidesDelta counting the slides whose title has.
Each counted field closes before the rest of its item, so a count names the item the model is writing rather than the last one finished.
A revision's deltas carry the artifact's current title until the model streams one of its own, read from the title map build_artifact_context built for the turn, because update_artifact streams title after the body and may omit it altogether.
The reader parses with jiter in partial mode, so a fragment that leaves a string open still yields the text inside it; a buffer that does not parse yields nothing rather than an error, and the buffer is dropped when the call completes.
When the call's arguments complete, the loop emits ArtifactSavingEvent (tool call id, step) for every call it announced, so the panel can say the writing is over while the tool validates the content and writes the version.
When the tool result carries metadata["artifact"] the loop emits ArtifactEndEvent (tool call id, artifact id, kind, title, version, change summary).
A failed call emits no end event; the client closes the skeleton on the failed tool_result.
None of the four - start, delta, saving, end - is persisted: the saved version is the record.
The body never reaches a span, a log, or the ToolCallEvent: the tools' display_args replaces content with content_chars, the llm_output span event carries the same view of each pending call, and the executor stamps campuscore.artifact.id, .kind and .version on the tool span after the save (services/observability/attributes.py).
narrate in services/agent/tool_narration.py labels the trace rows: the loader reads "Getting ready to write" and then "Ready to write", and an artifact call reads "Drafting "
The turn¶
ChatService._prepare_turn reads cc_artifacts for the person, by id and roles, once per turn (is_feature_enabled_for_person).
When it is on, every open draft on the conversation's artifacts is promoted to a version first (promote_conversation_drafts, the turn-start moment under "Drafts"), then the turn's agent is built with the artifact tools and the prompt variant that carries the ## Artifacts section, and the volatile prompt block gains the [ARTIFACTS IN THIS CONVERSATION] section from services/agent/artifact_context.py.
That section is one catalog line per unarchived artifact of the conversation (artifact_id, kind, title, current version), then the current content of the active artifact when there is one, in its artifact_prompt_text form.
Content past ACTIVE_BODY_MAX_CHARS (60,000) is not sent at all: the section says the artifact is too large to include and that update_artifact must not be called for it, because a cut blob cannot be handed back whole.
The active artifact is the id the request names in active_artifact_id when it is one of this conversation's, else the artifact versioned by the previous assistant message, so an immediate "make section two shorter" works with the panel closed.
Otherwise no body is sent: a body is up to the cap of uncached input on that turn.
An id that fails the ownership check is ignored, not an error, because the panel can be stale.
The root span's copy of the dynamic context shows [artifacts: N chars] in place of the section.
The assistant row is created at the turn's first text, note, tool call or artifact_end, whichever comes first, so a turn stopped right after the save still has a row to carry the card; a row with empty text and an artifact renders as the card alone, with "This answer is incomplete." under it when the turn was interrupted.
Every artifact_end of the turn is collected, and once the assistant row is saved _link_artifacts stamps it on those versions (ArtifactVersion.message) and on a new artifact's origin_message.
The turn's audit row lists the artifact ids under details["artifact_ids"].
The transcript card comes from ArtifactVersion.message, not from Message.agent_metadata, which is why artifact events are not persisted in the trace; a turn that revised a document carries the card as well as the turn that made it.
What artifacts cost the model per call, counted with o200k_base over the compact JSON of to_openai_tool() plus the section text, the way tests/test_artifact_tool_tokens.py counts it, measured on 2026-09-22:
| What the call carries | Tokens |
|---|---|
Always on: the ## Artifacts section (278) and the load_artifact_tools schema (96) |
374 |
Loaded on demand: create_artifact and update_artifact together |
10,274 |
The always-on row is paid on every model call of every turn a person with the flag takes, whether or not they ever ask for an artifact.
The loaded row is paid from the call after the model asks for the tools to the end of that turn, so a turn that makes an artifact spends one extra model call and opens the panel one round trip later, and a turn that makes none saves the whole 10,274.
Both are our own count of our own serialisation, not the provider's: driven against the dev stack on the same day, a model call carrying the loaded pair came back about 8,400 input tokens above one without it.
test_artifact_tool_tokens.py keeps both figures as constants beside a ceiling and a ten percent drift band, so a schema or prompt edit that moves either one turns the module red and the constant and this table are updated together.
An active body of about 3,500 characters costs about 900 tokens more on the turn that carries it.
A signed-in turn pays two queries for the flag, one for the catalog, and when no id is named two more to find the previous answer's artifact, then one for the active body when there is one.
The prompt¶
prompts/agent_system_prompt.py keeps the artifacts text in _ARTIFACTS_SECTION, composed only into AGENT_PROMPT_WITH_ARTIFACTS_TEMPLATE; the plain and guest variants carry no ## Artifacts section and name none of the three tools.
The section is four rules, and it says only what the model has to decide before it holds any artifact tool: what an artifact is and that it opens in a panel beside the chat, which kind follows which use, that a thing to keep becomes an artifact while a question gets an answer in the chat, and that load_artifact_tools is called first with the artifact tool in the next response.
The rules for writing one live on the two tool descriptions and their field descriptions instead, where the model reads them at the moment it needs them and a turn that makes no artifact never pays for them.
The member scope's coursework rule still wins over rule 3, so a graded deliverable is refused rather than drafted as a document.
The fallback template's rule 7 covers the case where the model has already made the document earlier in the turn: it stays in the panel and the reply says what to check.
test_agent_prompt.py pins that the section names load_artifact_tools, the four kinds and the load-first instruction, that none of the three tool names appears in the plain or the guest variant, and that the writing rules left the section for the tool descriptions.
The flag¶
cc_artifacts is a user-scoped flag registered in apps/auth/features.py under the Chat category and enabled per role in Settings (rbac-and-feature-flags.md; the registration recipe is adding-a-feature-flag.md).
It gates three things from one read: the tools and prompt section on the turn, the /api/artifacts routes (403 for a role without it), and the SPA surface (useFeatureEnabled("cc_artifacts") hides the sidebar link, the panel section and the library, and /artifacts redirects to chat).
A guest is 401 on the API and has no artifact tools.
The API¶
apps/main_app/apis/artifacts_api.py, all under /api/artifacts, every route owner-scoped through ArtifactService.owned() so another person's id is 404 everywhere:
| Route | Returns |
|---|---|
GET /api/artifacts?conversation_id&kind&after&limit |
ArtifactList: summaries only (no bodies), keyset-paged on (modified, id) |
GET /api/artifacts/{id} |
ArtifactDetail: the summary plus the current content, sources, and the version list |
PATCH /api/artifacts/{id}/rename |
the detail with the new live title; the version titles keep their history |
GET /api/artifacts/{id}/versions/{n} |
ArtifactVersionDetail: one older body |
POST /api/artifacts/{id}/versions/{n}/restore |
the detail with the new version on top |
POST /api/artifacts/{id}/archive |
204; the artifact leaves every list and the panel, its versions stay |
PUT /api/artifacts/{id}/draft |
ArtifactDraftDetail; DraftSaveRequest(title, content, base_version, revision) creates the draft at revision 0 or replaces it at the revision held; 409 draft_promoted, draft_conflict or stale_base (below) |
POST /api/artifacts/{id}/draft/discard |
204; the draft is gone and the current version stands |
POST /api/artifacts/{id}/draft/promote |
the detail with the person version on top; DraftPromoteRequest(revision, change_summary?), the same 409s |
GET /api/artifacts/{id}/flashcards.txt?target=anki\|quizlet |
the tab-separated file as an attachment (<title-slug>-<target>.txt); 404 for any other kind |
GET /api/artifacts/{id}/renders/{n} |
ArtifactRenderDetail, a union on status: queued or running, ready with page_count and the page routes in order, or failed with the sentence to show; 404 when the version or the row is not there |
POST /api/artifacts/{id}/renders/{n}/retry |
the same detail, queued again with the deadline counting from now; 409 render_not_failed when the row is neither failed nor past its deadline |
GET /api/artifacts/{id}/renders/{n}/pages/{i} |
one drawn page as image/png, inline, cached private, max-age=31536000, immutable; 404 when the render is not ready, the index is past the count, or the object is gone |
ArtifactDetail.content and ArtifactVersionDetail.content are the ArtifactContent union, so the generated client narrows on content.kind; the detail also carries draft (an ArtifactDraftDetail of title, content, base_version, revision, updated_at) when the owner has one open, and reading the detail runs the idle rule first.
The draft save takes content as a raw object parsed by the row's kind, and a payload whose own kind differs is 422.
AssistantMessage.artifacts on the transcript carries a MessageArtifact per unarchived artifact the row wrote a version of (ArtifactVersion.message), gathered in two queries for the whole transcript, so a reload shows the card on the creating turn and on every revise turn without a second request.
A MessageArtifact is the ArtifactSummary plus version, the number of the version that message wrote; a turn that wrote two versions of one artifact carries one card, naming the higher number.
Drafts¶
A person's edit is a draft until it becomes a version; a draft is never what the assistant reads, and a version is never edited in place.
save_draft creates the draft at revision 0, with the current version as base_version.
With a positive revision it replaces the draft at the revision the caller holds and bumps it.
A save is refused three ways, each a 409.
DraftConflict (draft_conflict) means the stored draft is at another revision, which is how a second window finds out about the first; it carries the stored draft.
DraftStale (stale_base) means a creation named a base that is no longer the current version.
DraftPromoted (draft_promoted) means a positive revision found no draft at all, because one of the promotion moments below has already turned it into a version; it carries the current version number.
A draft becomes a person version, with the change summary Edited by hand, at four moments.
The person clicks Save version (promote_draft).
An assistant turn starts in the conversation (promote_conversation_drafts, run before the context is built, so the model always reads the person's words); a draft its kind now refuses is left in place and logged, so one broken draft cannot end every turn in that conversation.
An export goes out (promote_open_draft).
A draft has sat untouched for DRAFT_IDLE, ten minutes, and anything reads the artifact or saves to it (promote_if_idle, run in its own transaction ahead of a save so the promotion outlives a refused save).
add_version and restore promote an open draft before they write, so the person's version always sits under the assistant's or the restored one.
Discarding deletes the draft and nothing else.
Streaming into the panel¶
The turn's frame reducer (lib/streaming/liveTurns.ts) handles artifact_delta by writing the title and the count to the store slot artifactStreams[toolCallId] and, for a document, accumulating the text in a ref that createAnimationFrameFlush writes to the store once per animation frame, so a body arriving one token at a time costs one render per frame, not per token.
It handles artifact_saving by moving that slot to stage: "saving", making the slot first when the call's kind never parsed, so a call nobody could name still says what is happening to it.
The skeleton (ArtifactView) reads the slot and shows the pulsing title and, for a document, the body as it grows through StreamedDocument; every other kind shows the line from progressLine ("Writing question 5...", "Writing card 12...", "Writing slide 3...").
A count names the item being written, so a five-question quiz ends on question five and no line ever names an item past the last; from the saving frame until the save lands every kind reads "Saving...".
The ArtifactCard under the live text and the panel heading read the same slot, so the kind's icon, the title and the saving line appear on all three at once.
StreamedDocument does not sanitise the whole body per frame.
lib/artifacts/streamedBlocks.ts finds the top-level blocks that have closed since the last settled boundary, and the view sanitises each settled block once and appends it to a settled container, re-sanitising only the open tail every frame; malformed markup only stops blocks from settling, so the view degrades to a longer tail, never to dropped text.
Measured in the live page on a 57,000-character body replayed at ten characters per frame, a frame costs 0.7 ms at the median and 2.9 ms at the 95th percentile, against 16 ms for one sanitiser pass over the whole body.
The slot is cleared by the chat route after it swaps the panel to the saved artifact at artifact_end, and by the turn store on every other end of a turn.
The SPA¶
web/src/lib/api/artifacts.ts holds the query keys and hooks; every mutation invalidates the artifact keys and the conversation keys, because the transcript card carries the title and version.
The turn store (lib/streaming/liveTurns.ts, read through useChatStream) keeps artifacts: ArtifactTurnItem[] per turn: drafting on artifact_start (filled in by artifact_delta, above), saved on artifact_end, failed when the call's tool_result reports failure.
A drafting item also closes as failed when the turn ends without an artifact_end for it: a stop, the response timeout, a stream that ends without done, or a done that reports an error.
A stopped turn that produced no text keeps its transcript block while it holds an artifact item, so the failed card stays on screen.
ChatRoute renders an ArtifactCard per item under the live text and, on desktop only and never over a collapse the person chose, opens the panel on the skeleton at artifact_start and swaps it to the saved artifact at artifact_end.
Both send paths pass active_artifact_id from the open panel detail, whether it is being read, edited or lies under a source opened over it (artifactBeingRead), and none of those is replaced by a skeleton.
MessageBubble renders the same card from message.artifacts for a saved row.
The card is a rounded row: the kind's icon tile, the title, a <Kind> · v<n> line, and an Open affordance; the change summary is not on it and lives in the versions menu.
Until a title has streamed the card and the skeleton heading both say what placeholderTitle returns for the call's tool, "Drafting" for a creation and "Revising" for a revision, which is why the skeleton detail carries the tool name as well as the tool call id.
The v<n> is the version that turn wrote, live and after a reload, so a revision that writes v2 leaves the creating turn's card reading v1; clicking either card opens the artifact as it stands now.
The panel state (uiStore.ts) is PanelDetail = ChatSourceDetail | ArtifactPanelDetail; the artifact variant is skeleton (the tool call id and tool name of the call being written), failed, open (an artifact id and an optional older version number), or edit (an artifact id).
ConversationPanel lists the conversation's artifacts above Context and Sources, asks for them only when the flag is on, and renders the open detail through DetailSlots: SourceDetail for a source, and ArtifactView for an artifact on show or under a source.
A source opened while an artifact is open to read opens over it.
The store's openDetail records the open artifact as the source's over, and keeps it when a second source opens over the first; a source opened over the list, a skeleton or an editor has over: null.
Openers pass a plain SourcePanelDetail, so only the store sets over.
ConversationPanel keeps the artifact in one slot, hidden under the source with the hidden attribute, so the same ArtifactView stays mounted.
Back returns to the artifact as the reader left it: a quiz on the same question with its checks and counts, a deck on the same card and flip, and the same quiz display.
A document keeps its scroll position too, which sourceOverArtifact.browser.test.tsx checks in Chromium and WebKit.
Focus returns to the control that opened the source, and a second Back reaches whatever opened the artifact.
While the source shows, the panel keeps the artifact's width, the header and focus follow the source, and neither Tab nor a screen reader reaches the hidden artifact.
Opening another artifact, or the agent opening a skeleton, replaces both.
A revision of the artifact under the source opens no skeleton: the saved version replaces the hidden artifact in place, and the attempt starts over because the body is keyed by version.
Collapsing the panel or switching chats unmounts the artifact and loses the attempt, though the source and its over survive, so Back still returns to the artifact.
PanelShell is a floating rounded card where it docks and a full-screen sheet where it does not; its one header row takes an optional heading in place of the plain title and an optional actions node before the collapse button.
heading is a function of the heading ref, because a detail opened from a chip, a card or a panel row takes focus on its heading and hands it back on close (see the side panel's focus).
It docks only in a window that holds it: the viewport at md or wider, and room for the panel's own PANEL_MIN_WIDTH beside CHAT_COLUMN_MIN_WIDTH of conversation once the rail and the card's inset have theirs (panelHasRoomToDock, read everywhere through usePanelDocks).
Otherwise it opens as the sheet, as it does on a phone - and a window narrowing under a docked panel takes the card down to its floor before the column goes under its own.
It takes its width from its caller and reports a drag back through onWidthChange, because how long a width lasts is the caller's policy: components/panel/panelWidth.ts opens an artifact, or a source over one, at half the room beside the navigation rail and a cited source or the list at PANEL_MIN_WIDTH, and ConversationPanel keeps a drag under the conversation it was made in for the life of the tab.
ArtifactToolbar.tsx fills those two slots for an artifact.
ArtifactHeading is the kind's icon tile, the inline-rename title, and the <Kind> · v<n> line, which reads Viewing v<n> of <m> on an older version.
It places the heading ref on every heading it renders - the skeleton, the failed and the loading heading, and TitleEditor's - so focus follows the detail when the agent saves it.
TitleEditor's button shows the title as its text and is named "
data-tip tooltip hung from the title's start, because a title beside the aria-label would become the button's description and a screen reader would say "rename" twice.
The heading around the button takes its name from the button, so it is announced as "KindLineSeparator (components/panel/separators.tsx), whose own margins are the whole of its spacing, so a quiz's display toggles sit the same distance from their dot as the version number does from its own.
The full page's meta line keeps MetaSeparator from the same module, whose spacing is written into the text so the line can wrap in front of a dot and never after one.
ArtifactActions is a row of icon buttons: Restore while an older version is shown, a history button holding the versions Menu, Edit, Print, a flashcards deck's Download, and Open full page.
A row of that menu reads v<n> · <summary or source> · <time>, the current version is marked, and a person version reads Edited by hand.
The controls themselves live once, in components/panel/ArtifactControls.tsx: VersionsControl (the dropdown the panel uses), HistoryControl (the button the page uses to open the drawer), RestoreControl, EditControl, PrintControl, DownloadControl, DraftActions (Save version and Discard) and TitleEditor.
They all wear the one face face() draws (components/panel/controlFace.tsx): a glyph in a 32px hover-wash button carrying its name as both aria-label and data-tip, the tooltip the stylesheet draws on hover and keyboard focus, in the panel's header row and in the page's control row alike, so the two surfaces cannot offer the same control in two shapes.
That face, ARTIFACT_CONTROL, carries a tap area on a touch screen, and both rows space their controls with tap-gap-8 (see the design system's tap area); in the panel, a quiz's kind line moves 20px below the title on a touch screen, because its two toggles and the title are all 24px controls.
RestoreControl and DraftActions are the two worded controls, by design: Restore carries a version number in its words, and Save version and Discard are edit mode's primary actions.
What is offered at all comes from lib/artifacts/kindTraits.ts, a Record<ArtifactKind, KindTraits> of four booleans: whether the kind exports, whether it has the Anki and Quizlet downloads, whether it prints, and whether it is a quiz, which is the one trait that carries the answer key, the doubled file formats and the display control together.
The controls, the export menu, the history drawer and the library all read the traits rather than the kind, so a new kind is a row in that table instead of a condition in each of them.
Restore is its own control rather than part of a history control, because the panel reaches its versions through the dropdown and the page through the drawer while restoring is the same act on both; each surface places it beside whichever history control it offers and tells it through focusAfter where the keyboard goes once Restore is gone with the version it restored.
Where a control leads is the caller's business: VersionsControl reports a choice through onShow (the panel writes it into the open detail, the page into the URL) and DraftActions reports leaving through onLeave.
versionLabel and versionLine (lib/artifacts/versionText.ts) are the one home of how a version reads in a list and of the "Viewing v1 of 3" line, so the dropdown, the drawer, the page header and the panel header cannot describe the same version in different words.
lib/artifacts/useShownVersion.ts is the matching reading of which body is on show: the current one the detail already carries, or an older one fetched on its own, with a number equal to the current version reading as current so nothing is fetched twice.
ArtifactView is the body: ArtifactBody in the .artifact-reading style (the product's own face at the reading size, --text-reading, with a reading measure and a tightened heading scale, shared with the full page; see the design system's type scale) switches on content.kind to DocumentView, QuizView, FlashcardsView, or SlidesView, and the sources are rows that open in the panel.
Editing¶
Edit switches the detail to edit, which mounts the lazy ArtifactEditor chunk (TipTap, the diff library and the form editors load only then; the chunk is about 470 kB, 147 kB gzipped).
useArtifactDraft (lib/artifacts/useArtifactDraft.ts) seeds from the open draft or the current version and owns the draft's life.
update schedules a save two seconds after the last change, promote is Save version, and discard is Discard.
status is saved, saving, unsaved with the error, conflict with the other window's draft, or behind with the version number that landed; a banner raised while a save was in flight stays up when that save lands.
holdInvalid(message) keeps a form that fails its own validation from being saved or promoted, and the message shows in the heading.
A save the server refuses with a 4xx other than a 409 becomes the same hold, with the server's reason, because sending that content again would be refused again.
The heading in edit mode reads the kind and the state ("Quiz · Saving...", "Flashcards · Write the front."), and the actions are Save version and Discard.
The two banners come from the 409s.
behind (draft_promoted or stale_base) says "Version n was made while you were editing." and offers three choices: Show changes (VersionDiff, a word diff between the person's promoted version and the current one), Keep editing (re-creates the draft on the new version with the local text) and Use version n (loads the new version into the editor).
When the current version is the one the draft started from, nothing new was written and the draft was discarded in another window, so the banner says that and drops Show changes.
conflict (draft_conflict) offers Reload draft.
DocumentEditor is TipTap over the sanitiser's allowlist with a formatting toolbar; a tag the editor has no node for keeps its words and drops its wrapper.
QuizEditor and FlashcardsEditor are forms over quizForm.ts and flashcardsForm.ts, and both ride the one editor chunk.
They share their field and label faces, STUDY_EDITOR_FIELD and STUDY_EDITOR_LABEL in editors/fieldClasses.ts, and one field wrapper, StudyField in editors/StudyField.tsx.
Each field sits inside the label that shows its words, which lends the field its tap area on a touch screen, and carries an aria-label that names its question or card, such as "Front of card 2".
Every field is plain text, a question type is a Select, and questions and cards move and delete with labelled icon buttons.
validate carries the server's caps and rules as sentences ("Mark one option as correct.", "Another card already has this front."), so a person is told inline, on the field, before anything is sent.
An HTML field the person did not touch keeps its markup (keepOrRewrite in lib/artifacts/textHtml.ts); one they edited is rewritten from the plain text, so a hand edit never silently flattens the rest of the artifact.
Each field's flattened text is captured once, when the form loads, and compared as a string afterwards: flattening is a DOMParser parse, and the form checks and rewrites every field of every question on each keystroke.
Taking a quiz and studying flashcards¶
Both views wear the same chrome, which lives in kinds/studyChrome.tsx: a progress header of one segment per question or card with the position and a running right and wrong count, the score ring, the Hint disclosure, the reveal, and the pill the actions are drawn as.
The look and all of the motion are the .study-* rules in styles/index.css, so the reduced-motion guard in that file's base layer switches every animation off without either view knowing.
QuizView opens in take mode: one question at a time, Check grades it against the key (a short answer is self-graded with I was right), Next moves on, Back returns to a question already checked with its answer still on it, and See results scores the attempt in points on the ring with Try again to restart; a hint folds open before checking.
Check marks each option row in place rather than opening a box below: the row the student chose reads Your answer, a right answer reads Correct answer or, when they did not pick it, Missed, the rest fade back, and the question's explanation opens once inside the right answer's row.
The row's own role="status" carries the verdict as a sentence for a screen reader, since on screen the rows carry it.
Show as text switches to the print form, and Answer key marks the key on it; both toggles live in useUiStore.quizDisplays, keyed by panel scope.
FlashcardsView opens in study mode (kinds/studyLoop.ts): the card is one button that Space or a click flips, with both faces mounted so it can really turn and the face pointing away marked aria-hidden.
Correct and Again sort it and move on with focus returned to the card, Undo takes the last sort back and empties the pile it went into, and the finish screen offers Start over, Shuffle and Only the ones I missed over the ring.
A sorted card is held on screen for 200ms while it slides away, which is the one timer in either view and is skipped when the student has asked for less movement.
Neither mode persists anything: an attempt lives in component state and is gone on close.
Slides¶
SlidesView shows the pages the server drew rather than a slide the browser lays out, so what a person reads is the PowerPoint file that downloads, page for page.
It reads useArtifactRender(versionRef), which polls every two seconds while the row is queued or running and stops the moment it is ready or failed.
A ready render is an ordered list of framed 16:9 pages at the panel's width, each with its "Page N / M" badge at the bottom right and, under a page whose slide has notes, a "Notes" disclosure; the images past the second load lazily, and every one carries its natural 1920 by 1080 size so the frame holds its shape before the bytes arrive.
While the render runs the same stack is grey placeholders under "Rendering the deck...", counted as the deck's slides plus one Sources page when the version cites anything, so the pages arrive without the layout moving.
A failed render shows the row's own sentence, a Retry that queues it again, and SlidesOutline, the deck read as text; a render that cannot be read at all shows the outline too, so a deck is never a blank panel.
The version's sources are in the History drawer whichever of those a person is looking at; the outline is the deck read as text and nothing else.
Which body a kind gets is KindTraits.body (lib/artifacts/kindTraits.ts): reading for prose, which keeps the reading measure, and pages for a deck, which takes a wider one, because a page stands for a whole slide.
KindTraits.edits is why a deck has no Edit button in the panel or on the page: a deck is asked for, not typed.
The backend door stays open - a valid deck draft is still a valid version - and nothing in the SPA reaches it.
Print and export¶
Print never navigates: usePrintArtifact (components/panel/PrintProjection.tsx) mounts the artifact a second time in a portal on document.body, puts artifact-printing on <html>, waits for the body to say it is ready and then for two animation frames so the portal has painted, and calls window.print(); afterprint takes the portal down, and a second request replaces the projection and prints again, so a browser that never fires afterprint cannot leave Print dead for the session.
What prints is the version the surface has on screen: the print job carries the ready arm of the ShownVersion its surface already computed.
Only an older version can be anything but ready, so Print is disabled while one is loading or could not be loaded rather than falling back to the current version.
The ready signal is ArtifactBody's onPrintReady: prose reports it on mount, and a deck reports it once every page image has decoded, because the dialog captures the document as it is at the moment it opens and paper that came out blank is not something a person can undo.
A deck whose pages are not there prints its outline instead, one page of the deck per sheet where they are.
A sheet holds the artifact and nothing else: the title as an <h1>, then the body as formatted.
There is no institution mark, no kind and version line, and no Sources list; the sources are in the History drawer, where every kind keeps them.
The heading goes on only where the body does not open with one of its own that says the title, which bodyOpensWithTitleHeading decides: every document the agent writes opens with an <h1> naming the artifact, and a heading above it would print the name twice.
It reads the body with DOMParser, takes the first node that is not whitespace, and counts it as the title when it is an <h1> whose words match, ignoring case and the whitespace around them.
A pages body gets no title heading either, because a deck's own first slide is its title and the heading would take a sheet of its own.
The browser's printed strip of date, title, address and page number goes with @page { margin: 0; } in the print stylesheet, which Chrome and Edge honour; in Firefox and Safari the strip is the person's own print setting and no page can turn it off.
With the sheet margin gone, a text body gets its margins back from .artifact-print-sheet, a print-only table whose thead and tfoot hold a 0.75 inch spacer row each and whose one body cell carries the same padding at the sides.
A table's head and foot repeat on every sheet the table crosses, which is what makes those bands a margin rather than a one-off gap.
A pages body stays outside that table, because one slide per sheet is a forced break and browsers do not agree about a forced break inside a table cell.
On screen .artifact-print-portal is display: none; the @media print block in styles/index.css hides every other child of the body and relaxes the shell's fixed height so the article flows across as many pages as it needs.
The same block paints the document white with a light color-scheme and forces the portal's own text to black, so a reader on the dark theme gets ink on paper rather than near-white text, or slate-950 behind it when they have left "Background graphics" on.
PrintControl is the one control behind both surfaces: a menu of Print quiz and Print with answer key for a quiz, a plain button for every other kind that prints, and nothing at all for flashcards.
Flashcards are studied rather than printed; the cards leave through the Download menu, which has Anki and Quizlet as plain anchors to the export route.
Everything else leaves through Export, which opens the menu described under "Export" below.
The library and the full page¶
/artifacts (ArtifactsRoute, a lazy chunk) is the library: a DataTable of every artifact the person owns with a kind filter, an Actions column and "Load more".
The Actions cell is one Menu labelled Actions for <title> holding Rename and Archive.
It opens with strategy="fixed", which portals the panel to the body and places it from the trigger's measured box, because the table's own overflow-x-auto would clip an in-tree panel; a panel placed that way cannot follow its trigger, so a scroll or a resize closes it.
/artifacts/:artifactId (ArtifactPageRoute, a lazy chunk) is the reading-width page, and it carries what the panel carries with History in place of the panel's Versions menu.
Its controls are one row of glyphs that does not wrap - History, Edit, Print, Export, a flashcards deck's Download and a quiz's display toggles - with Restore keeping its words while an older version is on show, and To conversation as a worded link beside them.
All artifacts is not in that row: it is a BackLink (components/ui/BackLink.tsx) above the title, the same chevron-and-words control the knowledge folder detail page uses to get back to its list.
It renders the same live ArtifactBody the panel renders, so a quiz is taken, flashcards are studied and a deck's pages are shown on the page as well, keyed by artifact and version so moving to another version starts the attempt over.
The document is the only thing in the reading column: everything about the artifact that is not the artifact is behind History.
A title link in the library opens the page with its h1 focused, and All artifacts hands focus back to that link, through lib/viewFocus.ts (see focus between an opener and the view it opens).
A source link on the page opens the cited file in the knowledge file viewer the app shell draws, because the page has no panel of its own; the viewer takes focus on its heading, hands it back to the link on close, and closes when the reader leaves the page.
History opens HistoryDrawer (components/panel/HistoryDrawer.tsx), a right-side drawer titled with the artifact's title over the kind and version line the page header uses, holding three stacked sections headed with their counts.
Versions lists every version newest first with the label the dropdown uses, and picking one shows it on the page and closes the drawer, because the reason to pick a version is to read it.
The row for the version on show carries aria-current and a heavier face, and picking that row only closes the drawer: reporting a version the page is already on would push a URL entry the browser's Back then has to be pressed twice to get past.
Exports is ExportRows (components/panel/ExportRows.tsx), which polls while anything is unfinished and says "Nothing exported yet." when there is nothing; a kind whose traits say it does not export gets no Exports section at all, and the drawer does not ask for a list it could not show.
It spins while the first answer is on its way and says "The exports could not be read." when the request failed, so a list that could not be read never passes for an empty one.
exportsRefetchInterval stops the polling on that failure, because re-asking every two seconds for as long as the drawer is open never comes back with more than the first attempt did.
Sources is the shown version's own sources, so the drawer credits what is being read rather than what is newest.
An older version and its sources arrive on their own, so the section spins while that is happening and says "This version could not be loaded." when it did not arrive.
The drawer is the Drawer primitive opened non-modal, so it is not a <dialog>: the document stays readable behind the scrim while the reader looks something up.
Nothing holds the keyboard inside it either, so Tab past the drawer's last control lands on the page behind the scrim, which is the trade taken deliberately for a panel the reader leaves by Escape, by the scrim or by Close.
Escape, the scrim and Close all close it, focus moves to its Close button on open, and the primitive hands focus back to the control the surface names in returnFocusTo - the page names its History button, because Safari does not focus a button on a mouse click and what had focus when the drawer opened is not reliably the opener.
The panel keeps its Versions dropdown instead, because the panel is already a narrow column beside the chat and a drawer over it would cover the thing it explains.
The version on show is ?v=<n> in the URL, so a version is a place that can be linked, reloaded and walked back through with the browser's own Back; absent, unparseable, or equal to the current version all read as current.
Edit happens in place: the page wraps itself in the same ArtifactDraftProvider the panel uses, the title becomes an input in the h1's place, the kind line becomes the draft's status, and the lazy editor chunk sits in the article's own measure.
Both frames mount the chunk through components/panel/artifactEditorChunk.ts, and the editor brings no scroller or measure of its own, so it fits whichever one holds it.
A reload mid-edit drops back to reading, and the next Edit resumes from the draft the server kept - useArtifactDraft seeds from artifact.draft whenever one is open.
Edit focuses the h1 around the title editor, not the title's own button, which turns into an input on every rename.
A screen reader announces that h1 by the button's name, "
QuizDisplayControl (components/panel/QuizDisplayControl.tsx) draws Show as text and Answer key in both places, reading and writing the display of the scope it is rendered in through useQuizDisplay and usePanelActions.
The setting belongs to the surface the quiz is open on - a conversation's panel, or the artifact page - so a turn ending in another chat, which opens and closes that chat's panel, cannot hand a reader on the page back to question one.
The page is keyed by the artifact and forgets its display through forgetQuizDisplay when it unmounts, so the next quiz opens to be taken rather than in the last one's answer key.
The surface comes from PanelScopeProvider's surface prop, which also decides where a source opens (see the panel scope).
Its variant is only where it sits: line is small grey words in the panel's kind line, icon is a glyph in the page's control row, and a pressed toggle inverts to the ink colour in both.
The page redirects to chat when the flag is off, the same as the library.
The drawn pages¶
A deck is read as pages rather than as text, so every version of a slides artifact is drawn the moment it lands.
RENDERED_KINDS (services/artifacts/renders/service.py) is the set of kinds that get a render; nothing else does.
queue_render(version) creates the ArtifactRender row inside the version's own transaction and registers a robust transaction.on_commit hook that publishes an ArtifactRenderMessage on the interactive queue.
A message can therefore never name a row the database has not got, and a queue that refuses fails the render row rather than the version: the deck is written either way, and a render that could not be queued is one the person can retry.
ArtifactService calls it from create, _append_version and restore, so an assistant write, a person's promoted draft and a restore all draw.
A deck that cites more sources than one page holds gets as many Sources pages as they need, so the placeholder count can be one or two short of the real one while the render runs.
The job (renders/job.py) is an adapter the shared runner drives (services/artifacts/jobs.py, below): it builds the PowerPoint file with build_deck, converts it to PDF inside a conversion slot, and rasterises each page with PyMuPDF at 144 dots per inch, which is 1920 by 1080 for a 13.333 by 7.5 inch slide.
Opening the PDF, drawing every page and storing each one is a single call off the ORM thread, and the document is closed before it returns, so a PyMuPDF document never outlives the thread that opened it.
Each page is stored at artifacts/{artifact_id}/v{n}/pages/{i}.png, a key of the version rather than of the render row, so a second run of the same version overwrites rather than orphaning; that is why a worker finishing late behind a retry is harmless and the retry does not have to wait for it.
The row then reads ready with its page count, and the time it took is logged and recorded as artifact.render.slides.
Reads apply the deadline rather than sweeping the table, the way the export service does: a row still queued or running JOB_DEADLINE after it was last queued reads as failed with "Nothing drew this deck in time.", and a worker that claims one that late marks it timed out rather than drawing pages the person was already told had failed.
The clock is queued_at rather than created_at, so RenderService.retry - which takes a failed row, or any row past its deadline - gets a full deadline of its own.
A transient fault goes back on the queue while the message's attempts last; anything else fails the row with "The deck could not be drawn." and no traceback in it.
The page route serves the object inline as image/png with Cache-Control: private, max-age=31536000, immutable, because a page is the drawing of one immutable version and never changes under the URL that names it.
The pages are a function of the version's content alone - no institution name or logo is drawn on any slide - so a second drawing of a version puts the same bytes under the same key and a browser holding the first is holding the right one.
It writes no audit row: a page view is a read of an artifact the caller already owns.
renders/__init__.py never re-exports job, for the reason exports/__init__.py records: the web image has neither python-pptx nor pymupdf.
render_deck_samples is the management command that writes both a capped and a realistic deck per theme as PNGs, which is how the design is judged against real output rather than against the code.
Export¶
An export sends one version of one artifact somewhere: back to the person as a file, into their Google Drive or OneDrive, into a Canvas course as a page, a file or a quiz, or into one of their own knowledge folders.
apps/main_app/services/artifacts/exports/ holds the whole of it and ArtifactExport (models/artifacts.py) is the one row.
The row¶
ArtifactExport carries the destination, the format, the version_number it named, the person who asked, the connection or knowledge_folder it is going to, the request's own params, a status of queued, running, ready or failed, and the queued_at the deadline counts from.
Which columns are meaningful depends on the destination, the format and the status, and every one of those rules is a check constraint rather than writer discipline: file_key, content_type and size_bytes exist only on a ready download, error_kind and error_message only on a failed row, external_url only where something was made at a provider, and finished_at exactly when the row is ready or failed.
external_url is allowed on a running row as well as a ready or failed one, because delivery records it the moment the provider answers; a queued row may not carry one, since nothing has been asked of a provider yet, and a download may not carry one at all.
EXPORT_FORMATS is the table of legal (destination, format) pairs, and it is the single source of two things: the request union's literals and the row's check constraint.
The API therefore cannot name a pair the database would refuse, and neither can drift from the other.
Each tuple is in the order the menu offers it, once formats_for(destination, kind) has dropped the formats this artifact's kind has no use for.
| Destination | Formats |
|---|---|
| Download | PowerPoint, PDF, Word, HTML |
| Google Drive | Google Slides, Google Doc, PowerPoint, Word, PDF |
| OneDrive | PowerPoint, Word, PDF |
| Canvas | Canvas page, Canvas quiz, PowerPoint, Word, PDF |
| Knowledge folder | PowerPoint, Word, PDF |
_KINDS_BY_FORMAT is that narrowing, and it is what both formats_for and the request-time check_kind read, so the menu and the refusal cannot disagree.
Only a slide deck becomes a PowerPoint file or a Google Slides deck, only a document becomes a Canvas page, only a quiz becomes a Canvas quiz, and flashcards have no export format at all, because they leave through their own Anki and Quizlet downloads.
A PowerPoint file is the deck's own slides, drawn by python-pptx on a blank layout, and the deck's PDF is Impress's conversion of that same file, so the two cannot disagree about where a line breaks.
A Google Slides deck is that PowerPoint file with Drive asked to convert it, the way a Google Doc is the Word file converted.
Making the file¶
ArtifactExportService.request promotes any open draft first, so the export names a version holding what the person is looking at, then either finishes the export inline or queues it.
An HTML download is made inline, because it needs no LibreOffice, and a second request for the same version, format and answer-key choice returns the row that already exists rather than making the file twice.
Every other format goes on the interactive queue as an ArtifactExportMessage, which the document worker takes beside its ingestion messages; a person is watching a status card for it, so it is queued the way a chat attachment is.
One download of a file may be in flight at a time, and the database is what holds that rather than a read the request path does first.
artifactexport_one_live_download is a partial unique index over the four things that decide which file a download produces - the artifact, the version, the format and the answer-key choice - across the rows that are still being made, so two clicks landing together cannot both become a row.
The request that loses reads the live row back and returns it, and the person watches the file the first click is making.
A live row nothing is coming for - one past JOB_DEADLINE whose message was lost - is failed as timed out first and the new row takes its place, so "Try again" on a dead row is never refused forever.
A third request that takes the freed slot in between is treated exactly like the first loser: the insert collides a second time, and the request is answered with the row that now holds the slot rather than with an error, since that row is making the file it asked for.
Rows whose params predate the answer-key choice read as null in the index, and a unique index compares no nulls, so they are outside it.
The queue send and the bucket put on this path run on bounded clients (clients/aws.py), because a person is waiting on them.
An unreachable queue or bucket ends the request in seconds with a sentence rather than holding the connection open for botocore's default minutes, while the worker's own poll keeps the unbounded client, since a read timeout shorter than its long poll would turn every quiet cycle into an error.
A bucket that will not take an inline export's bytes fails the row as storage, a kind of its own: nothing about the artifact is wrong, and the same request works once the bucket answers again.
The job (exports/job.py) is an adapter the shared runner drives (services/artifacts/jobs.py, below), so two deliveries of the same message cannot both build it.
render_export_document builds the HTML - the title as an <h1>, then the body, and nothing around them - and convert_document runs LibreOffice headless once for Word and twice for PDF, so both files come from one Writer document and cannot disagree.
It leaves that heading out where the body already opens with one that says the title, the rule paper follows above, decided here by _body_opens_with_title_heading; the <title> in the head, which is the tab's name rather than a heading on the page, is written either way.
Two conversions run at a time, because each one spawns a soffice of its own, and conversion_slot() (services/artifacts/jobs.py) is the only way to take one of the two.
A job holds its slot for its whole chain of conversions, so a PDF's two LibreOffice runs are one wait rather than two.
A job that finds both slots taken waits up to SLOT_WAIT and then goes back on the queue having spent none of its attempts, because more requests than slots is load rather than a fault; the row keeps the deadline it was queued with while it waits, so a process whose slots never come free ends at JOB_DEADLINE instead of queueing forever.
A conversion that hangs is given CONVERSION_TIMEOUT and is then killed with everything it started: soffice is a tree of processes, and the ones left behind would hold a CPU and a slice of the worker's memory until the task ran out of it.
A ready download's file lands under artifacts/{artifact_id}/v{n}/{export_id}.{ext} and is served by the download route, which audits the download itself.
A row that has sat longer than JOB_DEADLINE since it was last queued reads as timed out to the card and the history whether or not a worker ever picked it up, and a worker that claims one that late marks it timed out rather than finishing an export the person was already told had failed.
A transient fault goes back on the queue while the message's attempts last; a fatal one fails the row with a sentence the person can read and no traceback in it.
Building the file and putting it in the bucket both happen off the worker's shared ORM thread, and only the row writes run on it, so a slow conversion never holds up another message's reads.
Sending it somewhere¶
exports/deliver.py has one arm per destination, each ending in a link the status card can open.
The row is the only record of what a provider made, so every arm writes that link onto it the moment the provider hands one back and before it asks that provider for anything else.
The write is a compare-and-swap from running to running, so it lands only while this delivery still owns the row, and a worker that dies immediately afterwards leaves a row that still points at what exists.
The three connector destinations go through the shared connector gate (connectors/services/execution.py), so an export is governed exactly like a write the agent proposes: the connector has to be offered, its audience has to admit the person, and the institution has to have enabled the action.
The file the connector uploads is bound to the action context as a FileRef the resolver answers for, so a connector never reads storage itself.
The knowledge folder is not a connector at all: it is the folder upload sequence, so the file ingests exactly like one somebody uploaded (document-pipeline.md).
Those steps are not one transaction, so a step that fails after the bytes are in the bucket takes them back out and hands the row that named them to the registrar's discard, the faithful inverse of the reserve that made it: a folder showing a file whose bytes are gone is worse than a folder that never heard of it.
The row goes whether or not the bytes did, because the row is what the folder shows and nothing is ever going to ingest it.
A delete that itself fails is logged by the class of the fault alone and the original failure is what the row carries, because that is the one the person asked about.
Drive is told what to make of the file it is given: _drive_conversion passes convert_to as google_doc for a Google Doc, google_slides for a Google Slides deck, and file for everything else, which stores the upload as it is.
Nothing inside delivery is retried. A rate limit on the third question of a Canvas quiz, retried, would leave a second quiz behind, so a failure inside delivery fails the export with its own sentence and stops. The connection is read again at delivery, because it may have been removed or have expired while the export sat on the queue; an expired one fails with its own error kind so the card can say "Reconnect in Settings" rather than offering a retry that must fail the same way.
exports/destinations.py answers what this person can do with this artifact right now, and the menu draws itself from that: an offered destination carries its formats and the connections or folders to choose between, and one that is not offered carries the reason - no connection yet, a connection that needs reconnecting, an action the institution has not enabled, or a feature that is off for them.
It asks the same governance the delivery leg enforces, so nothing is offered that is already refused, though a destination shown as available can still fail at delivery when an admin disables an action in the meantime.
The Canvas quiz¶
A Canvas quiz is a Classic quiz created unpublished, then filled one question at a time (create_quiz and add_quiz_question on the Canvas connector), and exports/canvas_quiz.py is the mapping.
Canvas grades what it is sent, so the mapping is the grading: a weight of 100 on every correct answer and 0 on the rest is what makes multiple choice and multiple answers grade themselves, and true or false sends the two answers Canvas grades on by name.
An option's text is sent as plain text because Canvas shows answer_text as it is, while the stem keeps its HTML, which Canvas renders.
The artifact's short answer is self-graded against a model answer, which Canvas has no equivalent of - short_answer_question grades on an exact string match and would mark a correct answer wrong - so it becomes an essay question, marked by hand, with the model answer as the feedback every student sees once they have answered.
A hint has no Canvas home and is dropped rather than smuggled into that feedback, where it would arrive too late to help.
A quiz that fails part way keeps the link to what Canvas already made, and the row says how many questions landed so the person can open it and finish or delete it.
The row names the quiz from the moment Canvas creates it, before the first question is sent, so a worker killed inside the loop still leaves the quiz findable rather than sitting unnamed in the course.
The export menu¶
The Export control is a menu of destinations, each opening a submenu of the formats it takes.
Choosing a format is the whole of the common case: a download, or a drive the person has exactly one account on, starts on the click, because there is nothing left to ask.
The menu draws itself from GET /api/artifacts/<id>/destinations, fetched the first time it is opened, so a toolbar that is only ever read costs no request; a destination the person cannot use is a note row carrying its reason ("Connect Google Drive in Settings.", "Not allowed by your institution."), because a menu that hides half of what exists explains nothing.
A note is the menu's row for anything that says something rather than doing it - that reason, the spinner while the list loads, the sentence when it fails - and the keyboard walk steps over it, because there is nothing there to choose.
The three provider marks are bundled SVGs under web/src/assets/brands/, so the menu draws itself whether or not Google's, Microsoft's and Instructure's CDNs are reachable; Download and Knowledge folder use the app's own glyphs.
A quiz's answer key is a menu item rather than a checkbox: every file format appears twice, "PDF" and "PDF with answer key", and a Canvas quiz appears once, because it carries its correct answers and feedback as data.
ExportPicker opens only for what a menu cannot hold: which Canvas course, which knowledge folder, which of two connected accounts, and whether a Canvas quiz is practice or graded.
Its button names the outcome in full - "Create an unpublished page in CS101", "Add a Word file to Biology notes" - and is the confirmation for the whole export, so nothing asks the person to approve the same thing twice.
The Canvas course list is a live call to Canvas (/api/artifacts/exports/canvas-courses, filtered to the courses the person teaches), so the picker shows the course select only once Canvas has answered, and a failed answer is shown as the server's own sentence rather than as an empty course list.
Pressing the button starts the export and closes the picker: nobody is held in a dialog while a file is being made.
ExportStatusCards (components/panel/ExportStatus.tsx) is where an export reports itself: a card in the bottom corner of the viewport, one per export started in this session.
The cards live in a module-level store and are portalled to the body, and they are mounted once at RequireAuth, above every signed-in route, so a card started in the conversation panel is still there after the person opens the artifact's full page or takes a trip through Settings.
The store owns the request as well as the card, first attempt and Try again alike: an export happens at a provider, so the POST is a store action rather than something a component does on mount, which would send a second export every time that component remounted.
An export crosses the wire as ArtifactExportDetail (apis/artifacts_api.py), one of five variants that each carry only their own fields beside the ones every export has: ExportQueued; ExportRunning, with the provider's link once it has returned one; ExportReadyDownload, with the file's file_url, filename and size_bytes; ExportReadyDelivered, with the external_url of what the provider made; and ExportFailed, with its error_kind, its error_message and any link to what was already made.
status names the variant, and a ready row is split again on destination, so the card and the history switch on those two fields and read what they find with no null check.
The discriminator is a callable over the two fields rather than a union nested inside the ready arm, because the pinned openapi-typescript cannot read the mapping Pydantic writes for a nested union; the callable form comes out as a plain oneOf of the five, which the generator renders as an ordinary TypeScript union.
The shared export-list query polls every two seconds until every row in the list is terminal; a card watches its own row, and when that row settles it offers the file to download or the link to open, and starts the thirty-second close.
A failure shows the row's own sentence with Try again, except an expired connection, which sends the person to Settings; a request that never landed shows what the server refused, and a list that cannot be read says so rather than spinning.
A failed row that carries a link shows it under one fixed sentence saying something was already made at the destination and may be incomplete, whatever the card offers next: above the Try again that would make a second one, and between the failure and the sentence sending the person to Settings, because every way an export fails can leave something behind and the person has to be able to find what exists before they decide.
A settled card closes on its own button, or thirty seconds after it settles unless the pointer is on it or the keyboard is in it.
The Exports section of the full page's history drawer lists every export of the artifact with the same links.
The worker's side of both jobs¶
An export and a render are the same job over two different rows, so services/artifacts/jobs.py holds one runner and each job hands it an adapter: load, status, is_stale, claim, build, finish, requeue, fail and metric, typed to that job's own row and to what its build produces.
run_job owns the order of events and the exception ladder under it, which is what keeps the two from drifting apart the way two written-out copies of the same job did.
It answers the worker with one of three things - Completed, InFlight or Retry - and the worker's ladder over them ends in assert_never, so a fourth outcome is a type error rather than a message quietly left on the queue.
The claim is a ladder with five ends: a row the artifact's deletion took with it, a row already ready or failed, a row past its deadline, which the runner fails as timed out rather than building, a row a peer holds inside its deadline, which leaves the message on the queue for a later delivery, and otherwise the row this delivery owns.
Every status write goes through swap_status, a compare-and-swap that updates the row only while it still holds a state the write makes sense from, and that clears the error columns on every write that is not a failure, so a row can never read ready while still carrying why it failed.
A finish also accepts a row already failed with exactly the timeout, because the work behind that timeout was real: a worker that finishes just after a redelivery timed its row out still lands its file or its pages.
Of the two writes only one lands, and the one that loses changes nothing at all and reports its job as not ok.
A failure writing over a row never blanks an external_url, because a half-made quiz at a provider is still there whatever the row says.
services/artifacts/timing.py states the timing set once: how long a conversion may run, how many conversions a job chains, how many run at a time, how long a job waits for a slot, what the interactive queue's visibility timeout is, how often the worker extends it, and JOB_DEADLINE.
The figures only mean anything against each other, so tests/test_artifact_timing.py checks the arithmetic - an attempt fits inside one delivery of its message, the deadline covers the queue's three deliveries, the heartbeat beats the timeout - and reads the visibility figure back out of infrastructure/base/sqs.tf and infrastructure/elasticmq/elasticmq.conf so this copy of it cannot drift from what the queues are configured with.
The conversion timeout itself is stated where it is enforced, on the converter (content_extraction/converters/libreoffice.py), and timing.py reads it from there rather than keeping a second copy.
JOB_DEADLINE is fifteen minutes, and it is the one deadline both rows are read by.
It counts from queued_at, so a row the worker puts back on the queue gets a full deadline of its own rather than the remains of the first one, except a row that only waited for a conversion slot, which keeps the deadline it had.
Three deliveries of a message at the interactive queue's 300 second visibility timeout fit inside it, so a worker that dies holding a row leaves the queue its whole redrive budget before anything calls the row timed out.
While an artifact job runs, the worker hides its message again every HEARTBEAT_INTERVAL, each extension good for the whole visibility timeout counted from then, so a conversion that outlasts one timeout is not handed to a second worker.
The heartbeat stops when the job returns, and it stops at JOB_DEADLINE, because a job still running then is wedged and its message has to come back for a later delivery to time the row out and for the queue to move the message on.
An extension that fails ends the heartbeat and nothing else, logged once by the class of the fault: the job owns the row, and the worst a lost extension costs is a redelivery the claim already handles.
The worker runs every message through one shared ORM thread, so the runner puts only the row reads and the row writes on it and keeps each of them short. Everything a build does that takes time - the conversion, the drawing, the bucket puts, the delivery - runs off that thread, and nothing running off it touches the ORM.
The fleet those workers run in is sized by one target-tracking policy on the worker service, and its metric counts the interactive queue as well as the bulk one (infrastructure/app/worker.tf).
An export or a render is queued the way a chat attachment is, so a policy counting bulk alone would hold the fleet at one task while a rush of exports waited behind the two conversion slots that one task has.
Both queues also carry a depth alarm and a dead-letter alarm (infrastructure/app/monitoring.tf), so a queue filling up and a message that failed every delivery each reach Slack and email rather than only the person whose card never settles.
Audit and metrics¶
AuditAction has ARTIFACT_CREATED, ARTIFACT_UPDATED, ARTIFACT_RESTORED, ARTIFACT_RENAMED, ARTIFACT_ARCHIVED, ARTIFACT_EXPORTED and ARTIFACT_DOWNLOADED; the service writes one per action with the artifact id, kind and version number, never the body (audit-logging.md).
A promoted draft is an ARTIFACT_UPDATED row with source: person; an export is written where the export is requested with the version and the format, and ARTIFACT_DOWNLOADED where the file is served, so asking for a file and taking it are two separate facts.
A delivery to a connector also writes the connector's own connector_action_requested row with artifact_export as its source.
Draft saves are not audited: the promotion is the durable event.
The tools count artifact.created.<kind>, artifact.updated, and artifact.validation_failed.<create|update> through MetricsService, and the job records artifact.export.<format> in milliseconds on a successful export only.
What later items add¶
- Images in documents, edit operations, sharing beyond the owner, persisted study history (#466).
- A mind map kind, which this branch's history at
0159c2bbholds a first version of. - A slide editor built as a form per layout, and with it the Edit button on a deck.
- An image layout and an image source for a deck, once the five layouts have proved themselves.