Skip to main content

Sharing generated files (artifacts)

An artifact is a durable file produced by an agent (or uploaded into the platform) and exposed through a stable reference with a signed and a public URL. Artifacts let generated images, documents, videos and other binary outputs flow from the tool that creates them to the chat surface, to native channels (WhatsApp / Facebook / Instagram / X), to Pipedream components, and to MCP servers — without each tool re-implementing storage or signing.

What is an artifact?

  • A durable blob stored by IArtifactStorage (local disk, Azure Blob, or GCS), in a dedicated container/bucket (kroov-artifacts) for isolation and lifecycle.
  • Content-agnostic: the store carries an opaque MIME contentType and never inspects bytes.
  • Scoped to an owner (OwnerUserId) and optionally to a ProjectId, ThreadId, or SessionId.
  • Reachable through two URLs:
    • url — a short-lived signed URL (HMAC token for local, SAS for Azure, V4 signed URL for GCS).
    • publicUrl — a stable, headerless endpoint (GET /api/artifacts/{id}?token=<signed>) that renders directly inside <img> / <video> tags and can be fetched by Meta, Slack, or any HTTPS client.

Artifact reference shape

Every tool that produces or consumes an artifact uses the same JSON shape:

{
"id": "art_0123",
"contentType": "image/png",
"url": "https://api/artifacts/art_0123/signed?token=…",
"publicUrl": "https://api/artifacts/art_0123?token=…",
"fileName": "generated-1.png",
"sizeBytes": 84213
}

The model can reference either id (preferred; the platform re-signs on use) or publicUrl directly when calling downstream tools.

Generating an image

The first built-in generation tool is generate_image. It is registered in the agent orchestrator and the in-process Service runtime (no Kubernetes needed) and is gated by Artifacts:Enabled plus an image_generation provider capability.

Schema:

{
"prompt": "A flat vector illustration of a mountain at sunset",
"size": "1024x1024",
"n": 1
}
  • prompt (required) — text description.
  • size (optional) — 1024x1024 (default) or 1792x1024.
  • n (optional) — number of images to generate (default 1).

Result: for each generated image, the tool uploads the bytes to IArtifactStorage and returns an artifact reference. The conventional tool-result shape is:

{
"artifacts": [
{
"id": "art_0123",
"contentType": "image/png",
"url": "…",
"publicUrl": "…",
"fileName": "generated-1.png",
"sizeBytes": 84213
}
]
}

The same artifacts array convention is used by every generation/delivery tool — the model and the chat renderer read it generically.

Generating documents and ZIP archives

generate_document creates a downloadable file without an external model provider. It accepts:

{
"title": "Quarterly report",
"body": "First paragraph.\n\nSecond paragraph.",
"format": "pdf"
}

format is pdf, docx, or txt. PDF and DOCX contain the title followed by paragraphs split on blank lines; the body is treated as plain text rather than full Markdown. PDFs use a Unicode layout (word wrap and extra pages) so accented text and long paragraphs stay on the page. TXT is UTF-8 plain text.

generate_zip bundles inline UTF-8 text files, existing accessible artifacts, or both:

{
"fileName": "support-bundle.zip",
"files": [{ "fileName": "notes.txt", "text": "Investigation notes" }],
"artifactIds": ["01234567-89ab-cdef-0123-456789abcdef"]
}

Existing artifactIds are resolved with the current user's artifact access rules before their bytes are added to the archive. Both tools require Artifacts:Enabled and return the same artifacts array as generate_image, so assistant chat renders PDF, DOCX, TXT, and ZIP results as download chips.

How artifacts flow to channels

Native channels

The native action executor resolves artifactId → a freshly signed publicUrl before calling the provider client, so each tool only needs to accept an artifactId or a public URL:

  • WhatsAppwhatsapp_business__send_image (to, artifactId | imageUrl, optional caption). The client uploads the artifact bytes to Meta's media endpoint and sends an image message. See docs/WHATSAPP-BUSINESS.md for the native WhatsApp connection setup.
  • Facebook Pagesfacebook__publish_photo (message, artifactId | imageUrl). Posts to /{pageId}/photos with the artifact's publicUrl. See docs/FACEBOOK.md.
  • Instagraminstagram__publish_image already accepts a public HTTPS imageUrl; pass the artifact's publicUrl directly. See docs/INSTAGRAM.md.
  • X (Twitter)x_twitter__post_tweet accepts an optional mediaArtifactIds: string[]; the client uploads each artifact through media/upload (chunked) and attaches the returned media ids.

All four go through the existing grant, policy, approval, rate-limit, and action-ledger pipeline. artifactId resolution is content-agnostic — a future video artifact works the same way.

Pipedream

In PipedreamConnectClient.RunActionAsync, props named attachments, file, or attachment that receive { artifactId } (or a bare artifactId string) are resolved to a fetchable shape before the action runs:

  • Gmail attachments: [{ filename, content }] — base64 content is read from the artifact.
  • Slack file: { url } — the artifact's signed publicUrl is injected.

You can also pass publicUrl directly into any prop that expects a public HTTPS URL.

MCP

  • Input: when an MCP tool schema accepts a url or base64 field, pass artifactId and the executor resolves it to the shape the server expects. A helper IArtifactResolver.ResolveAsync(arg) is available to custom executors.
  • Output: McpProtocolClient.NormalizeToolResult now preserves non-text blocks returned by servers:
    • ImageContentBlock{ type: "image", mimeType, base64 }, and when Mcp:PersistImageResults is on, the image is uploaded to the artifact store and the result carries { artifactId, url }.
    • Blob resources → { type: "resource", uri, mimeType, blob }.
    • Mcp:PersistImageResults is off by default to avoid unbounded storage growth; enable it when you want server-produced images to render inline and be reusable.

Chat and sessions

Generated images and files render inline in the assistant chat and in agent sessions:

  • The assistant message binds an AssistantAttachment with Kind = Generated; MessageAttachments renders <img> for image/*, <video> for video/*, otherwise a download chip.
  • The client fetches the signed URL through a blob URL so auth-bearing endpoints render in <img> tags.
  • MarkdownRenderer accepts an allowImages option so agent-authored markdown can display generated images inline.

Configuration

Artifacts__Enabled=true
Artifacts__StorageProvider=Local # Local | Azure | Gcs
Artifacts__PublicBaseUrl=https://<api-host>
Artifacts__SigningKey=<high-entropy HMAC key>
# Provider-specific (mirror AssistantAttachments defaults):
Artifacts__Container=kroov-artifacts # Azure
Artifacts__Bucket=kroov-artifacts # GCS

# Optional — persist images returned by MCP servers:
Mcp__PersistImageResults=false

Artifacts:SigningKey is a secret — supply it via the environment or a secret manager, never in Git. With Artifacts:Enabled and StorageProvider=Local, the API refuses to start if the key is missing. Azure / GCS use provider-native signed URLs instead.

Security

  • Signed URLs — short TTL (15 min) for channel delivery, longer (1 h) for chat rendering. Local storage uses an HMAC token query param; Azure uses SAS; GCS uses V4 signed URLs.
  • Headerless renderGET /api/artifacts/{id}?token=<signed> accepts a token query param so <img> / <video> tags work without a bearer header. The token is bound to the artifact id and the signing key.
  • Per-owner / project scoping — every artifact carries OwnerUserId and optional ProjectId / ThreadId / SessionId. Download and sign endpoints verify access against these scopes.
  • Access checks on download — both the streaming endpoint and the signed-URL endpoint enforce the same row-level access checks; a leaked signed URL expires and is bound to a single artifact.
  • Lifecycle — every capture sets ExpiresAt from the owner's retention preference (User.AssetRetentionDays, default 30 days via Artifacts:DefaultRetentionDays). Pinning clears expiry (ExpiresAt = null) so the file is kept indefinitely. ArtifactPurgeWorker sweeps unpinned rows whose expiry has passed (interval Artifacts:PurgeIntervalHours, default 6h). Setting retention to 0 (Never) means new captures never expire.

Extensibility

The architecture separates the content-agnostic core from type-specific tools that are purely additive:

flowchart TD
GenTools["Generation tools (additive)<br/>generate_image, generate_video,<br/>generate_document, generate_zip, ..."] -->|store| Store
UserUpload["user upload"] --> Store
External["MCP/Pipedream binary outputs"] --> Store
Store["IArtifactStorage (content-agnostic)<br/>local/Azure/GCS<br/>+ signed URL"]
Store -->|artifact ref {id,contentType,url,publicUrl}| ToolResult["Tool result JSON"]
ToolResult --> ChatRender["Chat: render by contentType<br/>image -> img, video -> player,<br/>pdf/zip -> download chip"]
ToolResult --> Native["Delivery tools (additive)<br/>WA send_image/_document/_video,<br/>FB publish_photo/_video, X media, IG url"]
Store -->|publicUrl| Pipedream["Pipedream props<br/>Gmail/Slack files"]
Store -->|url/base64| Mcp["MCP tools + restore image/blob blocks"]
  • New generation tools (generate_video, generate_document, generate_zip, generate_audio) — one new tool per capability, calling the appropriate provider, then reusing the same IArtifactStorage. No core change.
  • New delivery tools (whatsapp_business__send_document/_video/_audio, facebook__publish_video, Instagram video_url for Reels) — one new tool (sometimes plus a client method) per type. No core refactoring.
  • Richer chat rendering (inline PDF preview, video player) — add branches to the renderer keyed on contentType. The data model does not change.

Adding a new file type is one or more new tools that produce or consume artifacts through the same abstraction; the store, table, signed URLs, base rendering, and inter-tool transport stay untouched.