Artifacts
An artifact is a versioned output attached to a work item: a report, a diff, a chart, a build log. Agents write them, people read them, and every version is kept — an artifact is never overwritten, only superseded by the next version of the same title.
This page covers the HTTP surface, where the bytes live, and the rules an upload has to satisfy. The error codes are in API error codes.
The shape of one
| Field | Meaning |
|---|---|
type |
What kind of thing it is, free text the product uses for grouping (report, diff, log) |
title |
The name a person reads. Versions are per (work_id, title) |
version |
1 for the first artifact with that title on that work item, then 2, 3, … |
content_type |
What the bytes are, decided from the bytes (see Uploads) |
size_bytes, sha256 |
Measured while the content streamed into storage, never taken from the client |
filename |
The uploaded name, sanitised for display. Never a path, never an identity |
external_uri |
Set instead of content when the artifact lives somewhere else |
metadata |
Free-form JSON the creator supplies |
storage_backend |
local or s3: which store holds the bytes |
There is no storage_key in the API. Where Kernel keeps the bytes is Kernel's business;
content is addressed by artifact id.
Routes
| Method | Path | What it does |
|---|---|---|
POST |
/api/v1/work/{work_id}/artifacts |
Attach the next version — JSON, or a multipart upload |
GET |
/api/v1/work/{work_id}/artifacts |
Every version of every artifact, newest first (?type=) |
GET |
/api/v1/work/{work_id}/artifacts/latest?title=… |
The highest version of one title |
GET |
/api/v1/artifacts/{id} |
One artifact version |
GET |
/api/v1/artifacts/{id}/content |
Its bytes, as an attachment |
Creating needs artifact.create (member and above); reading and downloading need
artifact.read (viewer and above). A session token holds artifacts:write and not
artifacts:read: a running agent records what it produced and does not read the work
item's artifacts back.
POST refuses an Idempotency-Key (400 idempotency.unsupported). The key's request hash
covers the body, and hashing a multipart upload means buffering all of it — the one thing
the upload path is built not to do. A repeated upload is not a duplicate anyway: it is
version n+1 of the same title, which is what the versioning is for. Read …/latest
before deciding to send it again.
Uploading bytes
curl -X POST http://127.0.0.1:8000/api/v1/work/$WORK/artifacts \
-H "Authorization: Bearer $KERNEL_TOKEN" \
-F 'type=report' \
-F 'title=Cache investigation' \
-F 'metadata={"tool":"pytest"}' \
-F 'file=@findings.md;type=text/markdown'
Recording one held elsewhere
curl -X POST http://127.0.0.1:8000/api/v1/work/$WORK/artifacts \
-H "Authorization: Bearer $KERNEL_TOKEN" -H 'Content-Type: application/json' \
-d '{"type":"link","title":"Build log","external_uri":"https://ci.example.com/builds/7"}'
Downloading such an artifact answers 409 artifact.no_content. Kernel does not follow a
URI an agent stored: fetching it would make Kernel's network the agent's.
Uploads
An upload describes itself three times — the declared content type, the filename's extension, and the bytes — and all three come from whoever the agent was talking to. Kernel stores it only when they agree.
- Size.
KERNEL_ARTIFACT_MAX_BYTES(25 MiB by default) is enforced while the body is arriving: the request's byte stream is counted at the source, so nothing over the limit reaches memory or disk. Over it is 413artifact.too_large. - Type. The magic bytes decide. A type Kernel recognises must agree with them: a type
that has a signature must show it, a text type must be text (valid UTF-8, no NUL), and a
known extension must name the same type. A disagreement is 415
artifact.type_mismatch— Kernel will not relabel a file for you. - Recognised types.
text/plain,text/markdown,text/csv,text/html,application/json,application/yaml,application/xml,image/svg+xml,text/x-diff,image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/gzip. - Anything else is stored, not refused. A type Kernel does not recognise — and an
upload that declares no type at all — is stored as
application/octet-stream, or as whatever the bytes turn out to be. The list decides what a file may be called, never whether it may be recorded: a system whose job is recording what agents did cannot refuse the first.parquetor.wasmone of them writes. It costs nothing, because every artifact downloads as an attachment either way. An unrecognised claim is dropped rather than believed, so the type on the row is always something Kernel checked. - Filename. Sanitised to a display name (directory components dropped, control characters removed, 200 characters). It never decides where anything is stored.
- Checksum.
sha256andsize_bytesare computed as the bytes stream past, so they describe what was actually stored. An upload may declare either as a field; the stored bytes are then held to it (422artifact.checksum_mismatch, carrying the claim and the measurement side by side) and nothing is recorded. Declaring neither is normal — what is recorded is the measurement either way. On a JSON create there are no bytes to measure, so both are taken as the caller's description of what is at theexternal_uri.
Downloads
Every download is
Content-Type: application/octet-stream, Content-Disposition: attachment,
X-Content-Type-Options: nosniff, Cache-Control: no-store — for every artifact,
whatever its stored type. An uploaded HTML page or SVG served inline would execute in
Kernel's own origin, with the viewer's session; one media type for every artifact is a rule
that cannot be forgotten in one branch. The stored type is on the JSON row for clients that
need to know what they fetched.
The filename reaches the header twice: an ASCII reduction in filename= and the real name
percent-encoded in filename* (RFC 6266). Neither can carry header syntax, because the
ASCII form is built from an allowlist of characters rather than by removing bad ones.
Where the bytes live
KERNEL_ARTIFACT_STORAGE selects the backend.
| Setting | Default | Meaning |
|---|---|---|
KERNEL_ARTIFACT_STORAGE |
local |
local or s3 |
KERNEL_ARTIFACT_DIR |
artifacts/blobs |
Root of the local store. Required in production, and must be absolute — KERNEL_ENV=prod refuses a relative value, naming the path it would have resolved to. Outside production a relative value is resolved once, at startup, against the working directory of the process reading it. The rule exists because kernel-server, kernel-worker and kernel-daemon read the same setting and need not share a working directory (D-047) |
KERNEL_ARTIFACT_MAX_BYTES |
26214400 |
Largest artifact an upload may carry |
KERNEL_S3_BUCKET |
— | Required when the backend is s3 |
KERNEL_S3_REGION |
— | |
KERNEL_S3_ENDPOINT_URL |
— | An S3-compatible endpoint (MinIO, Ceph); unset means AWS |
KERNEL_S3_PREFIX |
— | Prepended to every object key, so one bucket can hold several installations |
KERNEL_ARTIFACT_DIR is a startup contract in production, not a preference: the process
refuses to boot without an absolute one. Anything that validates the environment before a
deploy belongs in the same list as KERNEL_SECRET_KEY — M20's scripts/deploy_vps.sh in
particular, because a startup contract discovered by the deploy failing is a worse version of
the same rule.
S3 needs the extra: pip install 'kernel-agentos[s3]' (or uv sync --extra s3). Without
it, selecting s3 answers 500 artifact.s3_unavailable rather than failing at import.
Credentials are not Kernel settings: boto3 resolves them from the environment, an
instance profile or ~/.aws, which keeps them out of anything that prints configuration.
Keys
An object's key is <organization_id>/<work_id>/<uuid4> — three lowercase UUIDs, minted by
Kernel when it stores the bytes. Nothing a client sends contributes to it, and any string
that is not three UUIDs is refused by the backend before it touches storage. Traversal is
therefore not a check that has to keep up with new escapes: ../, an absolute path, a
Windows drive and a UNC path are all simply not keys.
The local store writes to a neighbouring .partial file and renames it into place, so a
stream that dies halfway leaves no half-written artifact for a later reader to serve.
Backups
The blob store and the database are one system: an artifact row without its object is a broken download, and an object without its row is unreachable. Back them up together, and restore the store no older than the database.
The malware-scan hook
Kernel ships no scanner and calls no external service. An operator who has one implements the protocol and installs it on the application:
from kernelos.storage import ScanRequest, ScanVerdict
class ClamScanner:
async def scan(self, request: ScanRequest) -> ScanVerdict:
# request.head is the first 4 KiB, already read; request.sha256 and request.size are
# measured; request.content() streams the whole object out of the backend.
if await looks_bad(request.sha256):
return ScanVerdict.rejected("known signature")
return ScanVerdict.ok()
app.state.artifact_scanner = ClamScanner()
The hook runs after the bytes are stored and before the artifact row is written. A verdict
that is not clean deletes the object and answers 422 artifact.rejected; no artifact is
created, so a rejected upload is invisible to everything downstream.
Viewing one in the browser
The artifact page (/o/{org}/artifacts/{id}) shows what the record says about the file —
type, version, size, the whole SHA-256, and who recorded it — and a preview when the
media type is one Kernel will render.
| Media type | In the page |
|---|---|
text/markdown |
Rendered, sanitised (see Markdown) |
text/plain, application/json |
As text, in a code block |
image/png, image/jpeg, image/gif, image/webp |
Shown, fetched through the download route |
everything else, including text/html and image/svg+xml |
Download only |
HTML and SVG are download-only at any size, and that is not a limit to be relaxed later: for those two, "preview" and "execute in Kernel's own origin" are the same act. An agent can write an artifact; if Kernel rendered an agent's HTML in its own origin, an agent could write a page that reads the operator's session, and the operator would be the one who opened it.
The preview reads at most 256 KiB and says so when it truncates. A preview is a convenience; the download is the file.
The type checked here is content_type — what the bytes were found to be — never the
type field, which is the free-text classification the creator chose (report, diff).
Comparing the wrong one silently matches nothing, which leaves every artifact download-only
and the rule above enforced by accident rather than by the check.
Markdown an agent wrote
Decision rationale, blocker descriptions and Markdown artifact previews are rendered by
kernelos.web.markdown: markdown-it-py with html=False, then nh3 with a strict
allowlist. Both passes matter, and not for the reason they appear to.
markdown-it refuses javascript:, data: and vbscript: in a link on its own, emitting
the whole thing as literal text. The famous payloads therefore never reach the sanitiser,
and a test suite built from them passes with nh3 removed entirely. What nh3 is
load-bearing for are the quiet schemes markdown-it renders into a real <a href>: ftp:,
about:, tel:, sms:, intent:, chrome: and blob: — of which blob: matters most,
since a blob URL can hold an entire HTML document and is same-origin with the page that
minted it.
Raw HTML in the source is escaped, not stripped: an agent quoting a snippet of markup in a rationale means to show it, and removing it would silently change what it said.
Links that leave the site carry rel="noopener nofollow" and open in a new tab; Kernel's
own relative links do neither, because scattering the operator's session across tabs every
time an agent referenced a work item would be its own defect.