Aleph
Concepts

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_trace stream is deliberately lossy (bounded mpsc + 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-reads ArtifactStore::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_key is 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):

ChannelBehavior
Panelartifact stored as ArtifactOrigin::Deliverable → triggers unseen_artifacts badge + auto-opens the browser tab
Telegram / DiscordCannot 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 / TUIWritten 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:

MethodPurpose
artifacts.listlist all artifacts of current session (optional origin filter)
artifacts.read_textread text blob (512 KB cap, UTF-8 boundary trim, read-only route — must not enter Mutate lane)
artifacts.publishwrite a Deliverable (only callable by artifact_publish tool)

Gateway capability byte route (src/gateway/server/artifact_route.rs):

GuardOn WSOn byte route
Plaintext-remote refusal (refuse_insecure_remote)426426
Cross-origin / DNS-rebinding (OriginPolicy)403403
Rate limitingshared RateLimiterindependent RateLimiter (image-gallery burst must not eat the chat.send bucket)
Authorizationconnect + device tiercapability in the path
Scopeper-connection sessionsession 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_artifacts badge stays in sync with listing diff (rename / refactor-safe)
  • deliverable.rs: single-card render + is_deliverable priority sort
  • files.rs: project tree folded into a secondary section of the artifact pane
  • preview.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.rsArtifactOrigin / ArtifactRecord / ArtifactError / encode_session_key (session-path injective + Windows-illegal-char safe)
  • src/artifacts/store.rsArtifactStore (put / list / read / purge_session), atomic byte writes, LRU eviction (Deliverable always last), directory sanitization
  • src/builtin_tools/artifact_publish.rsArtifactPublishTool (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 render
  • src/gateway/handlers/artifacts.rs — 3 RPCs (~650 lines)
  • src/gateway/server/artifact_route.rsArtifactRouteState + 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 submodules
  • interfaces/webchat/src/api/artifacts.ts — Panel-side typed RPC client

Key Constraints & Contracts

  • ArtifactRecord.id must be a bare lowercase hyphenated UUID (Uuid::parse_str strict parse + hyphenated().to_string() round-trip check) — the byte route's only source of trust, the id is part of the path
  • encode_session_key is injective% is itself encoded, so a%3Ab and a:b encode to distinct paths; this is the foundation of "two different keys never collide on one directory"
  • MAX_ARTIFACT_BYTES = 50 MB aligns with media::cache — an attachment that makes it through the media cache is never rejected here
  • Deliverable is always the last to be evictedeviction_rank lifts 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 laneartifacts.read_text is read-only; rate-limiter uses an independent RpcHeavy bucket that does not compete with chat.send for capacity
  • Single HTML render pathartifact_publish / session.export_html / read_text preview all go through export::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

On this page