Skip to content

Artifacts & Outbound File Delivery

When a vornik agent produces something a person actually wants to keep — a report, a CV, a patch, a generated PDF — you want the file, not a wall of text pasted into the chat. vornik has two built-in tools for that:

  • render_document — turn markdown into a real .md, .html, or .pdf file and deliver it.
  • send_artifact — deliver a file a completed task already produced.

Both deliver to wherever the conversation is happening — the file lands in the same Telegram chat, Slack thread, or email thread the request came in on. You never name a recipient; the destination is bound to the conversation.

Rendering a document on the fly

render_document converts markdown you (or the agent) supply into one or more formats and sends each file back to the chat. It runs deterministically, with no extra model call. The conversion happens inside the bundled agent image, in a short-lived container with no network access, never on the daemon host itself: the host needs podman and the agent image, not pandoc.

Parameters:

Parameter Required Description
content yes The markdown source (GitHub-flavoured). Raw HTML, images and YAML front matter are dropped; see below.
name yes Base filename, no extension (e.g. quarterly-report).
formats no Any of md, html, pdf, docx. Defaults to md, html and pdf.
  • md is always available — it's the source written to a file.
  • html is produced with pandoc in the agent image.
  • pdf is produced with pandoc + weasyprint in the agent image; WeasyPrint is not allowed to load any URL or file.
  • docx (a Word document) is produced with pandoc in the agent image.
  • The markdown is read as GitHub-flavoured markdown and sanitized the same way as the agents' document_render tool, so a document cannot make the renderer read a local file, fetch a URL, or carry a script into the output: raw HTML is dropped, every image becomes its alt text, YAML front matter is shown as plain text, and links keep their target only for http(s), mailto and # anchors. Tables, footnotes, strikethrough and task lists render as usual. Embed no images: there is no way to include one.
  • If the agent image isn't present locally (it is never pulled for this), or podman isn't available, the tool reports that rendering is not available rather than falling back to anything on the host. An md file you asked for is still delivered.
  • Each render is bounded: 1 GiB of memory, one CPU and 120 seconds by default (sandbox_tools.limits.render, sandbox_tools.timeouts.render), and a document over 16 MiB is refused before anything starts. A render that runs out of time or memory reports "timed out" or "exceeded its memory limit". Sandbox runs share a pool of two by default (sandbox_tools.max_concurrent), one of them kept for voice, so renders run one at a time and queue behind each other.

Extraction and voice run in the same sandbox

Since 2026.9.7 every tool that parses an uploaded or spoken file runs in the agent image under the same bounds, never on the daemon host:

Feature Tool Memory / CPUs / timeout Largest input Largest output
render pandoc 1 GiB / 1 / 120 s 16 MiB 256 MiB
pdf pdftotext 512 MiB / 1 / 60 s 256 MiB 256 MiB
image_ocr tesseract 512 MiB / 1 / 120 s 32 MiB 16 MiB
video ffprobe, ffmpeg 1 GiB / 2 / 300 s 2 GiB 256 MiB
audio ffmpeg, whisper-cli 2 GiB / 2 / 600 s 512 MiB 1 GiB
voice_stt ffmpeg, whisper-cli 2 GiB / 2 / 120 s 64 MiB 256 MiB
voice_tts piper, ffmpeg 2 GiB / 1 / 60 s 16 MiB 64 MiB

Override memory, CPUs and timeouts per feature under sandbox_tools.limits, sandbox_tools.cpus and sandbox_tools.timeouts; sandbox_tools.max_input_bytes, when set, replaces every feature's input bound. A run whose output passes its bound fails. The agent image declares its tools in the io.vornik.sandbox-tools label; a feature whose tool the pinned image does not declare (an image older than the release) reports "not available in the agent image" without starting anything, and vornikctl doctor runs every tool on a fixture to prove it works.

Which media containers are read. ffmpeg and ffprobe read only the uploaded file itself, and only as one of these containers: MP4/M4A/MOV/3GP, Matroska and WebM, Ogg (including Opus voice notes), MP3, WAV, FLAC, ADTS AAC, AVI, ASF (WMA/WMV), MPEG-TS, MPEG-PS (VOB), FLV, AIFF, AMR and CAF. A playlist or manifest (HLS .m3u8, DASH .mpd, an ffmpeg concat list, SDP) is refused rather than followed, and so is any other container (WavPack, APE, MXF, RealMedia, raw AC-3/DTS): extraction fails with ffmpeg's "Format not on whitelist". Convert such a file to one of the formats above first.

Audio extraction changed in 2026.9.7. It used to run OpenAI's Python whisper CLI on the host with its turbo model. It now runs whisper.cpp (whisper-cli) in the sandbox with a ggml model you configure: extractors.audio.model_path, or — when that is unset — voice.stt.model. With neither, audio extraction reports "not available". Quality and language coverage follow the model you pick (ggml-base.en.bin is English-only; a multilingual model such as ggml-small.bin detects the language).

Rendered files are transient: they're streamed straight to the chat and not kept in long-term storage. Use render_document for "make me this document now"; use send_artifact (below) for "give me the file that task produced."

Delivering a task's artifact

send_artifact retrieves a file a completed task wrote to its output and delivers it as a download.

Parameter Required Description
task_id yes The task whose output you want delivered.
artifact_name no A specific artifact; omit to send the task's first output artifact.

Two safeguards are worth knowing:

  • Project scope is enforced. A conversation pinned to one project can only retrieve that project's artifacts — you can't pull another project's files by guessing a task id.
  • Only operator-facing outputs are eligible. Selection is bounded to a task's published output artifacts; internal scratch and debug files can never be sent.

The name shown after completion is the harvested name, not necessarily the literal path from the workflow prompt. For example, an agent writes artifacts/out/research.md, while the durable catalogue may expose research-20260817-929c.md; the date and execution suffix prevent collisions between runs. Terminal task status includes this harvested inventory so callers do not mistake the renamed file for a missing output. Raw *-response-…md step transcripts are diagnostics, not deliverables; explicit artifact listings label them accordingly.

Where files go

File delivery is bound to the channel the conversation started on:

  • Telegram — delivered as a document upload (up to 50 MiB per file).
  • Slack — uploaded into the originating channel or DM thread (up to 50 MiB per file).
  • Email — delivered as an attachment on a reply in the same thread (multipart/mixed, with the body always listing the files produced).

Task-kind scheduled updates use the same channel binding. When a successful scheduled task publishes OUTPUT artifacts, Vornik automatically attaches them to the original Telegram, Slack, or email destination before posting the completion summary. Scratch/intermediate files are never forwarded.

Every scheduled update, reminder and delivered file says that an AI system produced it: on email each message ends with the AI-disclosure notice; on Telegram and Slack the chat gets the notice once, before the first update it receives, unless it was already told in an earlier conversation.

Attachments are collected from the scheduled task and any tasks it delegated to. A scheduled prompt frequently runs a router workflow that hands the real work to a child task, and the deliverable is written there — collecting only the scheduled task's own artifacts would attach the router's bookkeeping and none of the report. Per-step agent transcripts (<step>-response.md) are excluded everywhere: they record how a step answered, not what the run produced.

Email also enforces a per-message attachment size cap (configurable, below): over-cap attachments are skipped and logged, but the reply is still sent — with the body listing the files — so the recipient is never left with nothing.

Files going IN: two different mechanisms

Files reach an agent by one of two paths, and they are not interchangeable. A customer built a workflow and a gate against the wrong one, which is a documentation defect rather than a mistake on their part — so here they are side by side.

1. A file you attach to a task (the usual case)

You upload with /upload (or any companion delegate carrying inputArtifacts, or the REST create-task path, or a Telegram / email attachment). The task then carries:

  • context.inputFiles — the paths, and inputArtifactIDs alongside them;
  • an ## ATTACHED FILES block in the agent's prompt, naming each file;
  • the raw file staged into the container at artifacts/in/<name>, which is what an agent opens with an ordinary file read.

Only files Vornik stored as artifacts of the task's own project are staged. A path you write into context.inputFiles yourself, or one that names another project's artifact, is not staged; attach the file instead, and Vornik stores it and fills in both fields.

There is one wrinkle worth knowing, because it is the thing that bites: a document (PDF, EPUB, DOCX…) is normally extracted into project memory at upload time, and the raw bytes are then deliberately NOT staged — the extraction carries the content, and staging a 32 MB EPUB alongside it once blew an agent's context. Images, audio and video are always staged too, because their extractions are lossy derivatives: OCR text is not the picture.

If your workflow reads the file itself, declare require_input_artifacts: true on it. That declaration is the guarantee: the raw file is staged for that workflow no matter which path created the task, and no matter whether an adaptive route picked the workflow after the upload happened.

Size bound. The declaration cannot make a large file small. Raw staging for such a workflow is capped at 8 MiB per file; above that the extraction stands alone, because staging a 32 MB document next to its extraction is what blew an agent's context the first time. That is comfortably above what these workflows normally consume — a companion upload caps a single file at 512 KiB — but if you need a genuinely large file as a file, split it, or rely on the extraction and memory search instead of require_input_artifacts.

2. A summary of a delegated child's output

inputArtifactsSummary is a different thing entirely. It is the hand-off summary from delegated child tasks, and it appears only on a step that opts in with stage_child_artifacts. It has nothing to do with files you uploaded.

So a gate asserting inputArtifactsSummary on an upload-bearing task can only ever fail: the field is not absent because something broke, it is absent because it describes a mechanism the task never used. For an upload, assert on inputFiles / inputArtifactIDs, or simply have the step read artifacts/in/.

You want… Use Appears when
the file a user attached context.inputFiles, artifacts/in/<name> any upload path
the raw bytes guaranteed require_input_artifacts: true on the workflow always, once declared
what a child task produced inputArtifactsSummary only with stage_child_artifacts

Configuring email delivery

File delivery over Telegram works as soon as the bot is connected. Email delivery needs outbound SMTP configured on the project's email block:

email:
  smtp_host: "smtp.example.com"
  smtp_port: 587
  smtp_username: "[email protected]"
  smtp_password_env: "ASSISTANT_SMTP_PASSWORD"   # value read from this env var
  from_address: "[email protected]"
  # Per-message attachment cap in bytes; 0 = unlimited. 25 MiB shown.
  attachment_size_cap_bytes: 26214400

Without smtp_host + from_address, the email delivery path is disabled (the tools still work on Telegram). The password is taken from the named environment variable, never stored inline.

Putting it together

The agent reaches for these tools when a user asks for the artifact itself — "send me the CV", "share the report as a PDF", "email me the document". You can make that the default behaviour for a project by allow-listing the tool and nudging the model in its system prefix:

permissions:
  allowedTools:
    - send_artifact
    # render_document, file_*, memory_*, tool_search are built in

chat:
  system_prefix: |
    When asked for a document (a CV, a planning doc, an article, a report),
    ALWAYS use send_artifact (or render_document for fresh markdown) to deliver
    the file. Do not paste the document body into the chat.

Sending plain text email (not a file)

If you want the agent to email someone text rather than deliver a file, that's a separate tool, send_email, which composes a fresh message (to, subject, body, optional in_reply_to). It carries no attachment — reach for send_artifact / render_document when a file is the point.