Move bytes between Gini upload space, external URLs, and workspace files. Used by every attachment / file-upload / file-download flow regardless of the target system (Linear, GitHub, S3, Notion, etc.).
Move bytes between Gini upload space, external URLs, and workspace files. Used by every attachment / file-upload / file-download flow regardless of the target system (Linear, GitHub, S3, Notion, etc.).
Gini upload space — <id> references for files the user attached in chat, downloaded from a URL, or promoted from workspace.
External URLs — any https endpoint (signed PUT/GET URLs from APIs, raw file URLs, etc.).
Workspace files — files on disk under the agent's workspace root.
This skill ships four scripts you invoke via skill_run, plus a recipe for each common direction. The base primitive vision_query (asking the model to describe an image upload) is in core, not here — combine it with the scripts below when the model needs to "see" what it just moved.
When to use this skill
The user attached an image and asks you to file a Linear / GitHub / Notion issue with it.
The user pasted a URL pointing to file content (Linear attachment, GitHub raw, generic https URL) and asks you to ingest or describe it.
code_exec / terminal_exec produced a workspace file (chart, exported PDF, downloaded artifact) and you need to send it somewhere or run vision_query on it.
An MCP server returned an assetUrl / signed URL pointing to file content and you want to do something with the bytes.
PUT bytes from a Gini upload (chat attachment, downloaded file, promoted workspace file) to a signed URL the model obtained from an API's prepare step. Used in 3-step attachment flows: prepare via the API → signed-upload → finalize via the API.
skill_run({
skill: "attachments",
script: "signed-upload",
args: {
uploadId: "abc-123-...", // from the user message marker, signed-download, or promote-file
url: "https://uploads.linear.app/...?X-Goog-...",
headers: { // pass through whatever the prepare step returned, verbatim
"content-type": "image/png",
"x-goog-content-length-range": "36116,36116"
}
}
})
// → { ok: true, status: 200, bytesSent: 36116 }
// or { ok: false, status, error: "..." }
Only https URLs are accepted. The script never fabricates headers — pass through what the prepare step gave you. Signed URLs typically expire in 60 seconds; move immediately from prepare → PUT.
signed-download — external URL → Gini upload
GET bytes from a URL and store them as a Gini upload. Used to ingest content (Linear attachment URLs, GitHub raw files, user-pasted URLs, S3 presigned downloads) into the upload-addressable space so vision_query or signed-upload can consume them.
Only https URLs accepted. Body capped at 50MB. Inferred mime comes from the content-type response header; falls back to application/octet-stream.
promote-file — workspace file → Gini upload
Register a workspace-relative file as a Gini upload. Used when code_exec / terminal_exec left a file on disk that you want to attach somewhere or run vision_query on.
Path is workspace-relative and escape-protected (same guard as file_read).
When the user asked you to send / show them a file (a screenshot, a chart, a generated picture, a PDF, a CSV), promote-file is all you need. Its result includes an attachmentMarkdown field — a ready-to-paste markdown tag like  (for images) or [name](gini-upload://<id>) (for other files). Paste that tag verbatim into your reply at the spot where the attachment should appear — it renders inline there (an image shows as a picture, any other file as a download chip), so you can place a screenshot mid-sentence, right where you're describing it. Do not paste the raw uploadId on its own — use the provided attachmentMarkdown tag. The bare uploadId is only an argument to a follow-up tool (signed-upload, vision_query, materialize), never user-facing text.
materialize — Gini upload → workspace file
The inverse of promote-file: write a Gini upload's bytes to a workspace file. Used when you need a chat-attached (or downloaded / promoted) upload on disk so terminal_exec, code_exec, or a git flow can read the actual file — e.g. committing an image to an asset branch.
skill_run({
skill: "attachments",
script: "materialize",
args: {
uploadId: "abc-123-...", // from the user message marker, signed-download, or promote-file
path: "assets/diagram.png" // optional, workspace-relative; defaults to the manifest filename
}
})
// → { ok: true, path: "assets/diagram.png", absPath: "/abs/.../assets/diagram.png",
// mimeType: "image/png", size: 36116, filename: "diagram.png" }
Destination is workspace-relative and escape-protected (same guard as promote-file). When path is omitted it defaults to the upload's original filename (basename, sanitized) at the workspace root, or <uploadId>.<ext> when the manifest has none. absPath is the absolute on-disk path — hand it to commands that need an absolute path (e.g. git hash-object -w <absPath>).
Recipe patterns
Filing an issue with a chat-attached screenshot (providers with a signed-upload API)
Applies to providers that expose a public prepare → PUT → finalize upload API (Linear, S3 / any presigned-URL backend):
Prepare — call the provider's prepare-upload tool via mcp_call. Linear: prepare_attachment_upload({issue, filename, contentType, size}). The response carries the signed URL, headers to send verbatim, and an asset URL for the finalize step.
uploadId is in the user message marker: Attached image uploads (in order): - <id> (<mime>, <bytes> bytes). Read the mime and size from the same marker so the prepare call doesn't fail with EntityTooLarge / EntityTooSmall.
Targets without a public upload API (e.g. GitHub issues) don't use this recipe — materialize the upload to disk instead (recipe below) and follow that integration skill's own attach flow.
Hand off absPath to the integration skill that consumes a file — it owns the provider-specific attach flow (e.g. github-issues' "Attaching an image to an issue").
Chat-attached files are already delivered for you
When the user attaches a file in chat, the runtime delivers it to you in core — you do not need this skill to read it. The content arrives in the user message (a native document part, or inlined extracted text wrapped in <<<BEGIN/END UNTRUSTED FILE <nonce>>>> markers), and the file is already saved to your workspace at the uploads/<id>/<name> path named in the message. Read the workspace file directly with file_read / code_exec when you need more than the inlined preview. See ADR chat-file-attachments.md. This skill is for agent-initiated byte movement (URL downloads, promoting generated files, sending to an external system), not for reading the user's chat uploads.
Rules
Always invoke through skill_run with skill: "attachments". The scripts read JSON from stdin and write JSON to stdout — don't try to terminal_exec them by hand.
Pass signed-upload headers through verbatim from the prepare step's response. Omitting one or rewriting the casing usually means a 403 from the storage backend.
Signed URLs expire fast (often 60 seconds). On signed-upload failure, re-run the provider's prepare step rather than retrying with the stale URL.
Don't attempt vision on non-image uploads.vision_query only accepts image/png and image/jpeg. Convert via terminal_exec (sips -s format jpeg ...) and promote-file the result if you need to vision a different format.
The provider-specific prepare/finalize (or other attach) steps live in each integration's skill (Linear's SKILL.md documents Linear's args; github-issues documents GitHub's attach flow). This skill owns the byte-PUT / byte-GET / materialize middle steps only.