Add agent artifact upload workflow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-30 18:06:18 +00:00
parent 75f88c588c
commit 0bd13c23a9
6 changed files with 502 additions and 0 deletions

View file

@ -84,6 +84,9 @@ Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` ali
5. Keep repo plan docs dated and centralized. 5. Keep repo plan docs dated and centralized.
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file. When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
6. Attach inspectable generated artifacts.
When your task produces a user-inspectable file, upload it to the current issue before final disposition. Use `scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. See `doc/AGENT-ARTIFACTS.md` for `.mp4` and `.webm` examples.
## 6. Database Change Workflow ## 6. Database Change Workflow
When changing data model: When changing data model:

97
doc/AGENT-ARTIFACTS.md Normal file
View file

@ -0,0 +1,97 @@
# Agent Artifact Upload Workflow
Generated files that a board user or reviewer should inspect must be attached to
the Paperclip issue before the agent chooses a final disposition. A local
workspace path is not enough, because cloud users and reviewers often cannot
access the agent's disk.
Use the helper from the repo root:
```sh
scripts/paperclip-upload-artifact.sh path/to/output.webm \
--title "Walkthrough render" \
--summary "Rendered walkthrough for review"
```
The helper uses the authenticated Paperclip API from the current heartbeat
environment:
- `PAPERCLIP_API_URL`
- `PAPERCLIP_API_KEY`
- `PAPERCLIP_COMPANY_ID`
- `PAPERCLIP_TASK_ID`
- `PAPERCLIP_RUN_ID`
It uploads the file to
`POST /api/companies/{companyId}/issues/{issueId}/attachments` and creates an
artifact work product on `POST /api/issues/{issueId}/work-products` by default.
The command prints issue-safe markdown links for the final task comment.
## Completion Pattern
When a task produces a user-inspectable file:
1. Generate and verify the file locally.
2. Upload it with `scripts/paperclip-upload-artifact.sh`.
3. Keep the artifact work product unless the file is incidental; pass
`--no-work-product` only for supporting files that should not be promoted.
4. Link the printed attachment URL in the final issue comment.
5. Then set the final issue status.
Final comments should name the uploaded artifact, not just the local filesystem
path. Local paths can be included as diagnostic context, but they cannot be the
only access path.
## Video Examples
Upload an `.mp4` render:
```sh
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
```
Upload a `.webm` render:
```sh
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--summary "WebM walkthrough render"
```
The helper detects `.mp4`, `.webm`, and `.mov` content types. If a renderer uses
an unusual extension, pass the MIME type explicitly:
```sh
scripts/paperclip-upload-artifact.sh render.bin \
--title "Demo video render" \
--content-type video/mp4
```
## Direct API Pattern
If the helper is unavailable, use the same API shape:
```sh
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@dist/demo.mp4;type=video/mp4"
```
Then create a work product when the uploaded file is the deliverable:
```sh
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary @artifact-work-product.json
```
Use `type: "artifact"`, `provider: "paperclip"`, and metadata containing
`attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`,
`downloadPath`, and `originalFilename`.

View file

@ -212,6 +212,32 @@ Configure storage provider/settings:
pnpm paperclipai configure --section storage pnpm paperclipai configure --section storage
``` ```
## Agent Artifact Uploads
When an agent generates a file that a board user or reviewer should inspect,
attach it to the issue before marking the task complete. Do not rely on a local
workspace path as the only access path.
Use the helper from the repo root:
```sh
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
```
For WebM output:
```sh
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--summary "WebM walkthrough render"
```
The helper uploads the file as an issue attachment, creates an artifact work
product by default, and prints markdown links for the final issue comment. See
`doc/AGENT-ARTIFACTS.md` for the full completion pattern and direct API shape.
## Default Agent Workspaces ## Default Agent Workspaces
When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root: When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root:

View file

@ -0,0 +1,368 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/paperclip-upload-artifact.sh FILE [options]
Uploads a generated file from the current workspace to the current Paperclip
issue, then creates an attachment-backed artifact work product by default.
Required environment for live uploads:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID
Options:
--issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID)
--company-id ID Company id (default: PAPERCLIP_COMPANY_ID)
--title TEXT Work product title (default: file basename)
--summary TEXT Work product summary
--content-type TYPE Override detected upload content type
--status STATUS Work product status (default: ready_for_review)
--no-work-product Only upload the issue attachment
--no-primary Do not mark the artifact work product primary for its type
--output FORMAT markdown or json (default: markdown)
--dry-run Print resolved upload settings without calling the API
--help, -h Show this help
Examples:
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--content-type video/webm
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
json_bool() {
if [[ "${1:-0}" == "1" ]]; then
printf 'true'
else
printf 'false'
fi
}
detect_content_type() {
local path="$1"
local lower
lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*.mp4|*.m4v) printf 'video/mp4' ;;
*.webm) printf 'video/webm' ;;
*.mov|*.qt) printf 'video/quicktime' ;;
*.png) printf 'image/png' ;;
*.jpg|*.jpeg) printf 'image/jpeg' ;;
*.gif) printf 'image/gif' ;;
*.webp) printf 'image/webp' ;;
*.svg) printf 'image/svg+xml' ;;
*.pdf) printf 'application/pdf' ;;
*.txt|*.log) printf 'text/plain' ;;
*.md|*.markdown) printf 'text/markdown' ;;
*.json) printf 'application/json' ;;
*.csv) printf 'text/csv' ;;
*.html|*.htm) printf 'text/html' ;;
*.zip) printf 'application/zip' ;;
*)
if command -v file >/dev/null 2>&1; then
file --brief --mime-type "$path"
else
printf 'application/octet-stream'
fi
;;
esac
}
request_json() {
local method="$1"
local url="$2"
local body="${3:-}"
local response_file
local status_code
response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$body"
)"
else
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"
)"
fi
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
upload_file() {
local url="$1"
local path="$2"
local content_type="$3"
local response_file
local status_code
response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@${path};type=${content_type}"
)"
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
file_path=""
issue_id="${PAPERCLIP_TASK_ID:-}"
company_id="${PAPERCLIP_COMPANY_ID:-}"
title=""
summary=""
content_type=""
status="ready_for_review"
create_work_product=1
is_primary=1
output_format="markdown"
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--company-id)
company_id="${2:-}"
shift 2
;;
--title)
title="${2:-}"
shift 2
;;
--summary)
summary="${2:-}"
shift 2
;;
--content-type)
content_type="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--no-work-product)
create_work_product=0
shift
;;
--no-primary)
is_primary=0
shift
;;
--output)
output_format="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
--*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$file_path" ]]; then
printf 'Unexpected positional argument: %s\n' "$1" >&2
usage >&2
exit 1
fi
file_path="$1"
shift
;;
esac
done
if [[ -z "$file_path" ]]; then
printf 'Missing file path.\n' >&2
usage >&2
exit 1
fi
if [[ ! -f "$file_path" ]]; then
printf 'Artifact file does not exist: %s\n' "$file_path" >&2
exit 1
fi
if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then
printf 'Unsupported output format: %s\n' "$output_format" >&2
exit 1
fi
require_command curl
require_command jq
if [[ -z "$title" ]]; then
title="$(basename "$file_path")"
fi
if [[ -z "$content_type" ]]; then
content_type="$(detect_content_type "$file_path")"
fi
if [[ "$dry_run" == "1" ]]; then
create_work_product_json="$(json_bool "$create_work_product")"
is_primary_json="$(json_bool "$is_primary")"
jq -n \
--arg file "$file_path" \
--arg issueId "$issue_id" \
--arg companyId "$company_id" \
--arg title "$title" \
--arg summary "$summary" \
--arg contentType "$content_type" \
--arg status "$status" \
--argjson createWorkProduct "$create_work_product_json" \
--argjson isPrimary "$is_primary_json" \
'{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}'
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
if [[ -z "$issue_id" || -z "$company_id" ]]; then
printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2
exit 1
fi
api_base="${PAPERCLIP_API_URL%/}/api"
attachment="$(
upload_file \
"$api_base/companies/$company_id/issues/$issue_id/attachments" \
"$file_path" \
"$content_type"
)"
work_product="null"
if [[ "$create_work_product" == "1" ]]; then
is_primary_json="$(json_bool "$is_primary")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")"
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")"
if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then
printf 'Upload response did not include attachment path metadata.\n' >&2
printf '%s\n' "$attachment" >&2
exit 1
fi
work_product_payload="$(
jq -nc \
--arg title "$title" \
--arg summary "$summary" \
--arg status "$status" \
--arg runId "$PAPERCLIP_RUN_ID" \
--arg attachmentId "$attachment_id" \
--arg contentType "$content_type" \
--argjson byteSize "$byte_size" \
--arg contentPath "$content_path" \
--arg openPath "$open_path" \
--arg downloadPath "$download_path" \
--arg originalFilename "$original_filename" \
--argjson isPrimary "$is_primary_json" \
'{
type: "artifact",
provider: "paperclip",
title: $title,
status: $status,
reviewState: "none",
isPrimary: $isPrimary,
healthStatus: "unknown",
summary: (if $summary == "" then null else $summary end),
createdByRunId: $runId,
metadata: {
attachmentId: $attachmentId,
contentType: $contentType,
byteSize: $byteSize,
contentPath: $contentPath,
openPath: $openPath,
downloadPath: $downloadPath,
originalFilename: (if $originalFilename == "" then null else $originalFilename end)
}
}'
)"
work_product="$(
request_json \
POST \
"$api_base/issues/$issue_id/work-products" \
"$work_product_payload"
)"
fi
if [[ "$output_format" == "json" ]]; then
jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \
'{attachment: $attachment, workProduct: $workProduct}'
exit 0
fi
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
work_product_id="$(jq -r '.id // empty' <<<"$work_product")"
printf 'Uploaded artifact\n\n'
printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path"
printf -- '- Download: [%s](%s)\n' "$title" "$download_path"
printf -- '- Attachment ID: `%s`\n' "$attachment_id"
if [[ -n "$work_product_id" ]]; then
printf -- '- Work product ID: `%s`\n' "$work_product_id"
fi
printf '\nFinal comment snippet:\n\n'
printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path"

View file

@ -699,6 +699,13 @@ describe.sequential("agent skill routes", () => {
}), }),
expect.any(Object), expect.any(Object),
); );
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("scripts/paperclip-upload-artifact.sh"),
}),
expect.any(Object),
);
}); });
}); });

View file

@ -5,6 +5,7 @@ You are an agent at Paperclip company.
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. - Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
- Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit. - Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
- When your work produces a user-inspectable file, upload it to the issue before final disposition. Use `scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path.
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves. - Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists. - Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.