Skip to content

Asset Upload

The asset-upload tool lets a no-code agent ask a user to upload a document or image, store the bytes in object storage, and remember the asset so the agent and the user can find it again later.

It's the platform's first bi-directional tool: the agent prompts; the user responds in the portal; the agent gets the result and continues the conversation with full knowledge of the file.

When to use it

Reach for the asset-upload tool when:

  • The agent needs to look at a file the user already has (a business plan PDF, a recipe photo, a receipt scan).
  • The user wants the file to persist — tied to their per-agent memory, retrievable later.
  • You want the file to be citable in subsequent turns — by filename, by ID, or via the asset's URN.

It's not the right tool for one-off image attachments inside chat messages (use the message attachment surface for that, when it exists), or for documents that need to be public (assets are private to the user's per-agent memory).

Turning it on

Asset upload is opt-in per agent, default off — the same shape as the email tool. An org admin enables it once per agent:

  1. Open the agent detail page.
  2. Switch to the Settings tab.
  3. In the Capabilities section, check Allow this agent to ask users for uploads. The change saves on click.

Behind the scenes, this adds the canonical key asset:upload to the agentTools allowlist on the App that shares the agent's URN (the agent's caller-identity App). Until that toggle is on, any attempt to invoke the upload tool returns TOOL_NOT_ENABLED and the chat continues without the prompt being shown to the user.

The mutation behind the toggle (setAgentAssetUploadEnabled) is gated on org admin role. Builders without admin will see the checkbox but receive a PERMISSION_DENIED error on save.

For a step-by-step walk-through with a verification checklist, see the how-to guide.

What an upload looks like

Once the tool is enabled, the agent invokes a request during the chat turn. It supplies a human-readable prompt and (optionally) the MIME types and maximum size it expects.

Buno is asking for: a copy of your business plan
   [Choose file]    [Decline]

The user picks a file, confirms, and the bytes go directly from the browser to object storage. The server validates what landed, persists the asset metadata, and returns the new asset's id to the agent. From the agent's perspective the result is one of:

  • uploaded — the bytes are in. The agent gets assetId, filename, mimeType, and sizeBytes.
  • declined — the user dismissed the picker.
  • timeout — the user didn't respond in time (5 min default).

The user is never asked to retry on the agent's behalf; if they declined, the agent has to decide whether to ask differently or move on.

What the platform stores

Each upload becomes an Asset row tied to the user's per-agent memory:

Field What it is
filename Original filename (preserved verbatim).
mimeType Verified against the file's actual Content-Type after upload.
sizeBytes Verified against HeadObject.contentLength.
storageKey Pointer to the bytes in object storage. Encrypted at rest in encrypted memories.
scanStatus PENDING / CLEAN / BLOCKED. Downloads gated on CLEAN.
description Optional, agent- or user-provided.
uploadedAt When the upload completed.
uploadedBy The user who uploaded.

The asset has a stable URN: <memory-urn>:assets:<asset-id>. The agent (or the portal) can use this URN to refer back to the asset later — for download, for citation, for anything else.

What the platform DOES NOT store in the row

  • The original filename in the storage key. The key uses {org}/{memory}/{assetId}.{ext} — predictable, cleanable, free of user-supplied path-shaped characters.
  • The user-supplied filename anywhere it could leak across organizations.
  • The bytes themselves anywhere except object storage. The database row is metadata only.

Listing prior uploads

The agent can list assets in the user's per-agent memory at any time, paginated, optionally filtered by MIME type. Results are ordered newest first; soft-deleted assets are excluded by default.

A common pattern: when the user mentions something the agent remembers ("you sent me your business plan a few weeks ago"), the agent calls agentAssets(mimeType: "application/pdf") to find candidates and references them in its next message.

Downloading

When the agent (or the portal) needs the bytes — to embed in a prompt, to show to the user, to hand off to a content-extraction follow-up — it asks the platform for a short-TTL presigned URL and forwards that URL to the requester.

Default TTL: 5 minutes. Maximum: 1 hour. The URL is single-use within its lifetime and is never persisted. If the agent needs the URL again later, it requests a fresh one.

Downloads are gated on scanStatus = CLEAN:

  • PENDING returns SCAN_PENDING — try again in a few seconds.
  • BLOCKED returns SCAN_BLOCKED — the asset failed virus scanning and cannot be downloaded.
  • CLEAN returns the URL.

Limits

The defaults below apply when the agent does not specify limits. Builders can tighten per-agent or per-call:

  • Maximum size: 50 MB.
  • Allowed MIME types: PDF, common image formats (JPEG, PNG, GIF, WebP), plain text, common Office formats.
  • Maximum URL TTL: 1 hour.
  • Upload prompt timeout: 5 minutes.

The presigned upload URL itself pins the size and MIME type — if the user tries to upload a larger or different file, object storage rejects the PUT and the asset row is never written.

Soft-delete and restore

Assets soft-delete: the user (or an org admin) marks an asset deleted, the row's deletedAt is set, and the asset disappears from listings. The bytes are retained in object storage for 24 hours, during which the asset can be restored.

After 24h, a janitor sweeps the soft-deleted assets:

  • The R2 object is deleted.
  • The asset row is hard-deleted.

Trying to restore an asset whose 24h window has passed (or whose bytes the janitor has already swept) returns ASSET_BYTES_GONE. The platform does not silently re-create empty asset rows — the user knows the file is irrecoverable.

Encrypted memories

For Tier 2.5 encrypted memories (Know Yourself, eventually Tickfinder), the asset's storageKey is encrypted at rest with the same AES-256-GCM machinery that protects other sensitive fields. The decrypted form lives only on the request stack of a download URL mint.

If a user tries to download an asset from an encrypted memory without their session key cached (e.g. their session expired), the platform surfaces the same plain-language message the edge-conditions feature uses: "This conversation needs your password to unlock your private memory before it can continue. Please re-enter your password and try again."

What's deferred

These are real follow-ups, not handwaving — each gets its own spec when the v1 ships and demand surfaces:

  • Content extraction. OCR for PDFs and images, with extracted text saved on the asset for retrieval. The asset model already has a place for it; the pipeline is a separate change.
  • Cross-memory sharing. An asset is bound to one memory in v1. Sharing across users or agents needs explicit policy.
  • Asset → Reference promotion. Some uploads (a scanned book page, an uploaded paper) deserve to live in the citation graph. A future flow will ask "Add this upload as a reference?" at upload time.
  • Customer-managed encryption keys for the bytes themselves. v1 uses object-storage server-side encryption (provider-managed keys); the HIPAA/CMK tier is its own spec.
  • Versioning. v1 lets a user replace; the prior version is not retained.
  • Conversation routing — how agents invoke tools mid-conversation.
  • The asset-upload spec lives at spec-kits/specs/004-asset-upload/. The brainstorming session that settled the design is at hadron-concept/brainstorming/2026-04-28-asset-upload-design/010-summary.md.