Artifacts & Deliverables
Aleph's authoritative settlement layer for byte blobs that a session produces or receives; agents publish finished work products as Deliverables via artifact_publish, and the Panel Deliverables pane surfaces them through a capability byte route.
The artifact system is Aleph's authoritative settlement layer for every byte blob that a session produces or receives — uploaded files, tool-generated images, exported transcripts, and most importantly: Deliverables — reports / analyses / documents that an agent publishes as its finished work product. After Round 3 the artifact system is a "settlement layer + capability byte route + 4 origins + LRU quota" composite architecture; the old inspector right-rail is no longer the home of these.
Why an artifact store? The
agent_tracestream is deliberately lossy (boundedmpsc+try_send, drops frames rather than backpressuring the agent loop). A UI that treated a live event as the record of truth would show permanent "ghosts" whenever a frame was dropped. So live notifications are content-free invalidation pings, and every consumer re-readsArtifactStore::list.
Data Model (src/artifacts/mod.rs)
pub enum ArtifactOrigin {
Inbound, // from user / channel (upload, attachment)
Outbound, // produced by a tool during a run (generated image, chart)
Export, // generated by Aleph on demand (e.g. session transcript export)
Deliverable, // published by the agent as finished work — Panel pins to pane top and opens newest in browser
}
pub struct ArtifactRecord {
pub id: String, // UUID v4 (lowercase hyphenated)
pub session_key: String, // raw session_key (e.g. agent:main:main)
pub run_id: Option<String>,
pub origin: ArtifactOrigin,
pub filename: String, // sanitized: no directory components
pub mime_type: String, // stamped by caller; not sniffed from bytes
pub size: u64,
pub created_at: i64, // unix ms
}ArtifactRecord is immutable once written (the sidecar does not change), fitting the ArtifactStore::list = read_dir pattern. After Round 3 the Panel pins Deliverables to the top of the pane and opens the newest one in a browser — doing that for any of the other three origins would be wrong.
Disk Layout
No index file, no DB table. ArtifactStore::list is read_dir over sidecars:
<root>/<encoded_session>/<id>.bin # raw bytes
<root>/<encoded_session>/<id>.json # ArtifactRecord sidecar<root> defaults to <aleph_data_dir>/artifacts (deliberately outside std::env::temp_dir(): inbound media lands in temp and is remove_dir_all'd at end of every run, but artifacts must survive).
- Structural session isolation: a session's artifacts only exist under the directory its key encodes to;
encode_session_keyis injective (%is itself encoded), so two distinct keys can never collide on one directory; a reader holding session A's key cannot name a path in session B's directory - Atomic writes: temp file + rename; a crash never publishes a half-written blob
- Records are immutable: sidecars do not change once written
- Session-path encoding (
encode_session_key):%/://are percent-escaped —:is illegal in a Windows path component,/is the path separator on URLs, escaping gives one full directory + one full filename for every session
Quotas
pub const MAX_ARTIFACT_BYTES: u64 = 50 * 1024 * 1024; // 50 MB / blob (mirrors media::cache::MAX_FILE_SIZE)
pub const MAX_ARTIFACTS_PER_SESSION: usize = 200; // cap per session; excess evicted by LRU
const MAX_FILENAME_CHARS: usize = 200;
const MAX_MIME_BYTES: usize = 255;
const FALLBACK_FILENAME: &str = "unnamed";
const FALLBACK_MIME: &str = "application/octet-stream";The 50 MB cap mirrors the media cache — an attachment that makes it through the media cache is never rejected here. MAX_ARTIFACTS_PER_SESSION = 200 is a soft LRU cap: when exceeded, evict_overflow sorts records by eviction_rank and evicts the oldest Inbound / Outbound / Export. Deliverables are always last to be evicted (eviction_rank lifts Deliverable to a separate tier) — a run that floods 200 scratch images cannot push a session's report out the back door.
Publishing Deliverables
Agents use the artifact_publish tool (src/builtin_tools/artifact_publish.rs) to publish a finished work product as a Deliverable:
// src/builtin_tools/artifact_publish.rs (excerpt)
let spec = PublishSpec {
name: "report.md",
mime_type: "text/html", // rendered as a standalone openable HTML
bytes: rendered_html.as_bytes(), // the rendered full HTML (NOT raw markdown)
metadata: json!({
"run_id": current_run_id,
"session_key": current_session_key,
"tags": ["analysis", "weekly"],
}),
};
artifact_store.put(session_key, ArtifactOrigin::Deliverable, spec).await?;Key detail: artifact_publish accepts the Markdown the model already composed in the LLM side (ArtifactPublishArgs { title, content, figures }), and the tool itself renders it to HTML (crate::export::render_document_html) and stores it as text/html — so the artifact store holds a truly openable document, not the raw "report" string the model typed in the LLM context. The figures array takes artifact ids from earlier turns (resolved via artifacts.list), and export::collect_artifacts inlines their bytes under the two-layer byte budget (MAX_INLINE_ARTIFACT_BYTES = 4 MiB / MAX_INLINE_TOTAL_BYTES = 24 MiB).
The distinction from other origins: a transcript export is the conversation itself; a Deliverable is the report / analysis / document the conversation was for. Panel pins Deliverables to the top of the pane and opens the newest one in a browser — doing that for any of the other three origins would be wrong.
Tool / Channel Differentiation (Round 3 hardening)
artifact_publish must branch by channel on different paths (R5 notification principle + cross-channel consistency):
| Channel | Behavior |
|---|---|
| Panel | artifact stored as ArtifactOrigin::Deliverable → triggers unseen_artifacts badge + auto-opens the browser tab |
| Telegram / Discord | Cannot auto-open a window (no "Panel pane" for the user) — degrades to a banner notification + the text itself carries substance (not an empty link) |
| CLI / TUI | Written to the local Deliverables list; user prompted to open in Panel |
Across channels: artifact_publish must branch by turn.channel_id (not be implemented as channel-specific tools), and non-Panel channels must carry substance — otherwise a Telegram user sees only an empty link, which is worse than not sending.
RPC Interface (artifacts.*)
src/gateway/handlers/artifacts.rs (~650 lines) exposes three RPCs:
| Method | Purpose |
|---|---|
artifacts.list | list all artifacts of current session (optional origin filter) |
artifacts.read_text | read text blob (512 KB cap, UTF-8 boundary trim, read-only route — must not enter Mutate lane) |
artifacts.publish | write a Deliverable (only callable by artifact_publish tool) |
Gateway capability byte route (src/gateway/server/artifact_route.rs):
| Guard | On WS | On byte route |
|---|---|---|
Plaintext-remote refusal (refuse_insecure_remote) | 426 | 426 |
Cross-origin / DNS-rebinding (OriginPolicy) | 403 | 403 |
| Rate limiting | shared RateLimiter | independent RateLimiter (image-gallery burst must not eat the chat.send bucket) |
| Authorization | connect + device tier | capability in the path |
| Scope | per-connection session | session resolved from the capability |
The capability is in the path (/artifact/{cap}/{id}/{filename}), never in a query — a ?cap= would survive into any access log or error trace. Unknown capability → 404, expired capability → 404 (indistinguishable from "this id does not exist" — a cap holder cannot enumerate other people's artifact ids).
artifacts.read_text is a read-only route — the Mutate lane does not see it (Panel doesn't use the apply_layer_two path to read), keeping artifact flow's "read-only + disk-isolated" purity.
Panel "Deliverables" Pane
interfaces/webchat/src/components/artifacts/ (5 submodules: deliverable / files / mod / preview / row):
- Replaces the old inspector — Deliverable is now a first-class pane citizen
- Text preview + artifact-driven badge + visible fallbacks (round-3 review: 4 defects + 1 capability gap fixed)
- Pin + Open in browser: newest Deliverable opened in browser; pinned to pane top
unseen_artifactsbadge stays in sync with listing diff (rename / refactor-safe)deliverable.rs: single-card render +is_deliverablepriority sortfiles.rs: project tree folded into a secondary section of the artifact panepreview.rs: in-pane text preview (without leaving the session)
interfaces/webchat/src/api/typed/artifacts.ts provides a typed client:
const list = await artifacts.list({ session_key, origin_filter: "deliverable" });
const text = await artifacts.read_text({ session_key, id });
const artifact = await artifacts.publish({ session_key, name, mime_type, content_base64 });Session HTML Export
src/export/ (collect.rs, document.rs, export.css, markdown.rs, mod.rs, page.rs, session_html.rs) provides:
session.export_html— export session transcript as self-contained HTML (self-contained CSS)session.export_markdown— pure-function markdown export (for downstream scripts)export.css— styles for exported docs (aligned with Panel theme)collect_artifacts/render_document_html/page.rs— artifact byte inlining (two-layer budget:MAX_INLINE_ARTIFACT_BYTES = 4 MiB/MAX_INLINE_TOTAL_BYTES = 24 MiB)
Exports land as ArtifactOrigin::Export and are browsable / re-openable from the Deliverables pane. artifact_publish's figures array goes through the same collect_artifacts path — single HTML render path; styles are byte-identical inside the Panel and when sent out.
Error Types
pub enum ArtifactError {
TooLarge { size: u64, max: u64 }, // > MAX_ARTIFACT_BYTES
InvalidId(String), // id is not a bare lowercase hyphenated UUID
NotFound(String), // no such id under this session
Metadata(serde_json::Error), // sidecar codec
Io(std::io::Error), // filesystem
Root(String), // Aleph data dir unresolvable
}InvalidId is a structural rejection (not NotFound) — a client sending ../../etc/passwd or {not-a-uuid} is rejected outright without touching the filesystem. Metadata errors = sidecar JSON corruption, do not propagate to the agent context (corrupt sidecars still return Ok(None); the read path never breaks the main flow).
Key Source Files
src/artifacts/mod.rs—ArtifactOrigin/ArtifactRecord/ArtifactError/encode_session_key(session-path injective + Windows-illegal-char safe)src/artifacts/store.rs—ArtifactStore(put/list/read/purge_session), atomic byte writes, LRU eviction (Deliverable always last), directory sanitizationsrc/builtin_tools/artifact_publish.rs—ArtifactPublishTool(ArtifactPublishArgs { title, content, figures }, renders to HTML then stores, fail-soft per R7)src/export/{mod,collect,document,page,markdown,session_html,export}.rs— HTML / Markdown rendersrc/gateway/handlers/artifacts.rs— 3 RPCs (~650 lines)src/gateway/server/artifact_route.rs—ArtifactRouteState+artifact_routes()+ capability decoding (prevents cap holders from enumerating other people's ids)interfaces/webchat/src/components/artifacts/{mod,deliverable,files,preview,row}.rs— Panel 5 submodulesinterfaces/webchat/src/api/artifacts.ts— Panel-side typed RPC client
Key Constraints & Contracts
ArtifactRecord.idmust be a bare lowercase hyphenated UUID (Uuid::parse_strstrict parse +hyphenated().to_string()round-trip check) — the byte route's only source of trust, the id is part of the pathencode_session_keyis injective —%is itself encoded, soa%3Abanda:bencode to distinct paths; this is the foundation of "two different keys never collide on one directory"MAX_ARTIFACT_BYTES = 50 MBaligns withmedia::cache— an attachment that makes it through the media cache is never rejected here- Deliverable is always the last to be evicted —
eviction_ranklifts Deliverable to its own tier, so a run that floods 200 scratch images cannot push the report out the back door - Capability is in the path, not in a query —
/artifact/{cap}/{id}/{filename}, never?cap=…(which would survive into access logs / error traces) - Read route does not enter the Mutate lane —
artifacts.read_textis read-only; rate-limiter uses an independentRpcHeavybucket that does not compete withchat.sendfor capacity - Single HTML render path —
artifact_publish/session.export_html/read_textpreview all go throughexport::render_document_html; styles are byte-identical inside the Panel and when sent out
26.7.x Addendum
Round 3 Review (4 defects + 1 capability gap fixed)
artifacts.list / pane render / unseen_artifacts badge stay in sync with the listing diff (rename / refactor-safe). The 4 Panel defects + 1 capability gap were fixed in one batch in Round 3.
Relationship to Loop / Goal / Strategy
artifact_publish is a common output path for standing goals and strategies — the Strategy plan produced by /goal · /loop · /workflow, the long-term notes promoted by GoalLessonsPromoteStage, and the skill lessons produced by feedback_distill are all written to ArtifactStore as openable HTML reports via artifact_publish. Strategy::goal_id / Goal::lessons / the output of FeedbackDistillStage can be reverse-traced through note_graph_query to their ArtifactRecord.id, completing a full audit loop.
Relationship to voice.format
artifact_publish's figures array is an artifact-to-artifact reference (ArtifactRecord nesting), independent of voice.format's "direct literal polish" — the two paths solve different problems: artifact_publish is "post-report settlement + render"; voice.format is "live caption display polish".
See Also
- Extensions Store — Hub single source
- Session Mode —
session_modestatic partition - Loop / Goal / Strategy — Strategy is a common Deliverable path
- Architecture
src/artifacts/— module inventory - Export module source — HTML / Markdown render
- Artifact byte route source — capability validation
AI Providers
Unified model-provider adapters with streaming, live routing, ordered failover, cooldowns, circuit breaking, and truthful usage accounting.
Voice Conversation Runtime
Streaming ASR, TTS provider fallback, speech regularization, voice-as-context, Panel live captions, and the end-to-end conversation loop. Backed by portable_pty-embedded terminals and WhisperLive/Deepgram dual-protocol adapters.