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.pdffile 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. |
mdis always available — it's the source written to a file.htmlis produced withpandocin the agent image.pdfis produced withpandoc+weasyprintin the agent image; WeasyPrint is not allowed to load any URL or file.docx(a Word document) is produced withpandocin the agent image.- The markdown is read as GitHub-flavoured markdown and sanitized the same way
as the agents'
document_rendertool, 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 forhttp(s),mailtoand#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
mdfile 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, andinputArtifactIDsalongside them;- an
## ATTACHED FILESblock 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.