name: attachments description: "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.)." license: MIT allowed-tools: "skill_run vision_query read_skill" metadata: gini: version: 1.0.0 author: Gini platforms: [macos, linux]
Attachments
You move bytes between three places:
- 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_execproduced a workspace file (chart, exported PDF, downloaded artifact) and you need to send it somewhere or runvision_queryon it.- An MCP server returned an
assetUrl/ signed URL pointing to file content and you want to do something with the bytes.
The four scripts
signed-upload — chat-attached upload → external URL
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.
skill_run({
skill: "attachments",
script: "signed-download",
args: {
url: "https://uploads.linear.app/asset/abc.png",
headers: { authorization: "Bearer ..." }, // optional, depends on the URL
filename: "screenshot.png" // optional, defaults to URL basename
}
})
// → { ok: true, uploadId: "xyz-456-...", mimeType: "image/png", size: 36116 }
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.
skill_run({
skill: "attachments",
script: "promote-file",
args: {
path: ".charts/sales-q4.png",
mimeType: "image/png" // optional, sniffed from extension when omitted
}
})
// → { ok: true, uploadId: "ghi-789-...", mimeType: "image/png", size: 24512 }
Path is workspace-relative and escape-protected (same guard as file_read).
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. - PUT bytes —
skill_run({skill: "attachments", script: "signed-upload", args: {uploadId, url: <prepared.url>, headers: <prepared.headers>}}). - Finalize — call the provider's finalize tool. Linear:
create_attachment_from_upload({issue, assetUrl, title}).
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.
Reading a screenshot the user posted as a URL
skill_run({skill: "attachments", script: "signed-download", args: {url}})→{uploadId}.vision_query({uploadId, question: "describe this image"})→{answer}.
Describing an existing attachment on a Linear issue
mcp_call({server: "linear", tool: "get_attachment", arguments: {id: "..."}})→ response includes the asset URL.skill_run({skill: "attachments", script: "signed-download", args: {url: <assetUrl>}})→{uploadId}.vision_query({uploadId, question: "..."})→{answer}.
Sending a generated chart to Linear
- Generate via
code_exec(matplotlib / d3 / etc.), save under./chart.png. skill_run({skill: "attachments", script: "promote-file", args: {path: "chart.png"}})→{uploadId, mimeType, size}.- Run the 3-step Linear flow with that
uploadId.
Putting a chat-attached screenshot on disk
When an integration needs the actual file (not an upload id) — e.g. a git flow that has to git hash-object the bytes:
skill_run({skill: "attachments", script: "materialize", args: {uploadId: "<id-from-marker>"}})→{path, absPath}.- Hand off
absPathto 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_runwithskill: "attachments". The scripts read JSON from stdin and write JSON to stdout — don't try toterminal_execthem 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-uploadfailure, re-run the provider's prepare step rather than retrying with the stale URL. - Don't attempt vision on non-image uploads.
vision_queryonly acceptsimage/pngandimage/jpeg. Convert viaterminal_exec(sips -s format jpeg ...) andpromote-filethe 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.mddocuments Linear's args;github-issuesdocuments GitHub's attach flow). This skill owns the byte-PUT / byte-GET / materialize middle steps only.