Kernel

← Documentation

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 413 artifact.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 .parquet or .wasm one 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. sha256 and size_bytes are 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 (422 artifact.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 the external_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 absoluteKERNEL_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.

Keyboard shortcuts

Shortcuts are a faster way to do what the keyboard already does, never the only way.

?
Show this help
Esc
Close dialogs
/
Search
g h
Go to Home
g p
Go to Projects
g w
Go to Work
g n
Go to Needs You
g r
Go to Runtimes
g a
Go to Activity
c
Create Work
j k
Move between rows in Needs You and work lists
Enter
Open the focused row
a r
Approve or reject the focused approval
e
Edit the open item