A small local app that bulk-creates Shortcut stories from a CSV file. You paste your API token, pick a team, upload a CSV, map columns, review a dry-run preview, and import.
- 7-step wizard: API key → team → CSV → column mapping → epic decisions → preview → import
- Auto-resolves names → IDs for epics, iterations, workflow states, members (by email/mention/name), labels, and projects
- For each unmatched epic, you choose Create or Skip before importing
- Dry-run preview shows every story with warnings before anything is sent to Shortcut
- API token is stored only in your browser (
localStorage)
The Shortcut API does not allow cross-origin browser requests, so a fetch from the SPA directly to api.app.shortcut.com fails with a CORS error. To avoid that, the app calls a same-origin path /sc-api/* and a server-side rewrite forwards it to https://api.app.shortcut.com/api/v3/*. The browser only ever talks to the same origin; your token rides along on each request as the Shortcut-Token header.
The rewrite is configured in two places:
- Local dev / preview —
vite.config.tsuses Vite's built-in proxy for bothvite(dev) andvite preview. - Production (Vercel) —
vercel.jsondeclares the same rewrite as a Vercel route.
- Bun ≥ 1.0 (or npm/pnpm) for install and the dev server.
bun install
bun devOpen http://localhost:5173.
bun run build
bun run preview # serves dist/ at http://localhost:4173 with the same /sc-api proxyThe repo is Vercel-ready — no extra configuration needed.
vercel # interactive first-time deploy
vercel --prod # promote to productionVercel auto-detects Vite (build command bun run build, output dist/). The vercel.json at the repo root declares the rewrite that proxies /sc-api/* to Shortcut's API. Your Shortcut token is never stored on Vercel — it stays in each user's browser localStorage and is sent as a header on every request.
- Get a Shortcut API token at https://app.shortcut.com/settings/account/api-tokens. Copy it.
- Open the app, paste the token into Step 1, click Fetch workspace. The app loads your groups, workflows, epics, iterations, members, projects, and labels.
- Step 2 — pick the team (group) the imported stories should belong to and the workflow whose states you'll reference.
- Step 3 — drag & drop a
.csvfile (first row is the header). - Step 4 — map each Shortcut field to a column from your CSV. The app auto-guesses based on header names (e.g.
Title→name,Story Type→story_type); change anything that's wrong, and set columns you don't need to — none —. - Step 5 — for each epic referenced in the CSV that doesn't already exist in Shortcut, choose Create (the app will create it under the selected team) or Skip (the story will be imported with no epic).
- Step 6 — review the dry-run table. Rows with errors are skipped; rows with warnings still import (e.g. unrecognized owner, invalid estimate).
- Step 7 — click to start the import. Epics are created first, then stories one at a time, with live progress and a per-row link to each created story.
A starter file with all supported columns is available from the upload step (Download sample CSV) or directly at http://localhost:5173/sample.csv while the dev server is running. Open it, fill in your stories, and upload it back into the app.
The CSV must have a header row. Column names can be anything — you'll map them in Step 4. Empty rows are ignored. Available fields:
| Field | Notes |
|---|---|
name |
Required. Story title. |
description |
Markdown allowed. |
story_type |
feature (default), bug, or chore. |
estimate |
Integer. |
epic |
Match by name. Unmatched values prompt you in Step 5. |
iteration |
Match by name. |
workflow_state |
Match by name within the workflow chosen in Step 2. |
labels |
Comma-separated. New labels are created automatically by Shortcut. |
owners |
Comma-separated emails or @mention_names. |
requested_by |
Single email or @mention_name. |
tasks |
Semicolon- or newline-separated. |
external_id |
Free-form string. |
deadline |
ISO date (2026-08-15) or anything Date parses. |
project |
Match by name. |
A minimal example:
name,story_type,epic,estimate,owners,labels
Implement onboarding tour,feature,Onboarding,3,alice@example.com,"frontend,onboarding"
Fix sign-up email typo,bug,,1,bob@example.com,bugThe same content is also available inside the app via the Help button in the header.
An empty cell (or a column you didn't map) is treated identically: the field is simply not sent to Shortcut, and Shortcut applies whatever default it has.
| Empty field | Effect |
|---|---|
name |
Row is skipped with an error. (The only required field.) |
story_type |
Shortcut defaults to feature. |
workflow_state |
Story uses the default state of your selected workflow. |
requested_by |
Defaults to the owner of the API token. |
epic |
No epic, and you're not prompted for it in Step 5. |
| anything else | Field is omitted; story imports without it. |
Whole-row empty CSV lines are dropped silently before any of this.
The CSV uses human-readable names; the app resolves them to Shortcut IDs against the workspace it loaded in Step 1.
- Epic — case-insensitive name match. Unmatched names go to Step 5 where you decide create (epic is created under the team you picked in Step 2 before stories are imported) or skip (story imports with no epic).
- Iteration — name match. Unmatched → row warning, no iteration set.
- Workflow state — matched by name within the workflow you picked in Step 2. Unmatched → row warning, workflow default state used.
- Owners / Requested by — matched against email,
@mention_name, or full name. Each entry is resolved independently — unresolved entries become row warnings. - Project — name match. Unmatched → row warning, dropped.
- Labels — passed through by name; Shortcut auto-creates any new ones.
The Step 6 preview classifies every row before anything is sent:
- Error — the row is skipped entirely. Today this only happens when
nameis missing. - Warning — the row still imports, but a single field is dropped or replaced with a default (e.g. unrecognized owner, invalid
estimate, unknown workflow state, invalid date).
Both error and warning details are listed per row in the preview, so nothing is hidden until import.
| Field | Validation |
|---|---|
story_type |
Must be feature, bug, or chore. Anything else → warning, falls back to feature. |
estimate |
Must parse as a number. Otherwise → warning, dropped. |
deadline |
Must parse as a Date. Otherwise → warning, dropped. |
Shortcut limits the API to about 200 requests per minute. Imports run sequentially — one story per request — so 500 stories take about three minutes. If a single story fails, the rest of the import continues; failures are reported per row at the end.
- Your token is kept in
localStoragein your browser. - The browser only calls a same-origin path (
/sc-api/*); a server-side rewrite forwards the request — token header and body — toapi.app.shortcut.com. The rewrite is Vite's proxy in development and Vercel'srewritesrule in production. Neither stores the token. - Nothing is sent to any third party.
bun dev # Vite dev server with proxy at http://localhost:5173
bun run build # Type-check + bundle to dist/
bun run preview # Serve the built dist/ at http://localhost:4173 (proxied)
bun run lint # ESLintsrc/
api/shortcut.ts Shortcut API client (calls /sc-api/* — proxied)
api/types.ts Shortcut payload/response types
components/ Each wizard step is one file
lib/csv.ts Papaparse wrapper
lib/mapping.ts Auto-guesses CSV header → Shortcut field
lib/resolve.ts Resolves names → IDs and builds story payloads
types.ts Field list and labels for the UI
vite.config.ts Dev + preview proxy: /sc-api → api.app.shortcut.com/api/v3
vercel.json Vercel rewrite that does the same thing in production
For a deeper walkthrough see docs/:
docs/architecture.md— layers, data flow, why the proxy exists, design decisions left outdocs/code-organization.md— file-by-file referencedocs/wizard-flow.md— state model and step-by-step runtime behavior
- Shortcut rate-limits the API at 200 requests/minute. Imports run sequentially, one story per request, so a 500-story CSV takes ~3 minutes.
- The token is sent on every API call from the browser to the same-origin path, then rewritten upstream to Shortcut. Neither Vite (dev) nor Vercel (prod) logs request bodies by default.
- If a story fails to create, the rest of the import continues — failures are shown per row at the end.