Import tasks
Migrate your tasks from Notion (or any other tool) with their comments, attachments, people and original dates, without duplicates when you re-import.
Bring your tasks over exactly as they were. An import creates or updates a project’s tasks from a JSON file: with their Markdown description, status, priority, environment and version, who they’re assigned to and who requested them, their links, their comments (with author and date) and their files. Creation, last-edited and completion dates are preserved.
It’s idempotent: each task is identified by its source (e.g. notion) and its externalId. Importing the same file again updates those tasks instead of duplicating them. So you can migrate, review, and import again with a corrected mapping.
In two steps
# 1. Dry run: writes nothing. Reports what would be created or updated and what wasn't mapped
fxc tasks import notion.json --project-id <id> --dry-run
# 2. The real import (running it again updates, never duplicates)
fxc tasks import notion.json --project-id <id>
The CLI validates the file, uploads the local images embedded in the Markdown, imports the tasks and then uploads their attachments. Files already on the task (same name and size) are skipped when you re-import.
From an assistant, the MCP tool import_tasks does the same ({ file, projectId, dryRun }). Over HTTP it’s POST /projects/:id/tasks/import (add ?dryRun=true for a dry run); you need to be on the project’s team. The API doesn’t receive files: only the CLI understands local paths.
The file
{
"version": 1,
"source": "notion",
"statusMap": { "Not started": "No Iniciada", "In progress": "Working", "Completed": "Terminada" },
"priorityMap": { "High": "Alta", "Medium": "Media", "Low": "Baja" },
"createMissingPeople": true,
"tasks": [
{
"externalId": "1a2b3c",
"externalUrl": "https://www.notion.so/acme/Login-roto-1a2b3c",
"title": "El login falla con Safari",
"description": "Pasos:\n\n| Paso | Resultado |\n|---|---|\n| Abrir /login | OK |\n| Enviar | Error 500 |\n\n",
"status": "In progress",
"priority": "High",
"environment": "producción",
"version": "1.4.0",
"tags": ["auth", "safari"],
"assignee": "Leonardo",
"requester": "Alexander Navarro",
"createdAt": "2025-02-10T09:30:00Z",
"updatedAt": "2025-02-12T16:00:00Z",
"links": [{ "url": "https://github.com/acme/web/issues/42", "label": "Issue" }],
"comments": [
{
"author": "Leonardo",
"createdAt": "2025-02-11T10:00:00Z",
"content": "Lo reproduzco. Adjunto el informe.",
"attachments": ["./notion-import/informe.docx"]
}
],
"attachments": ["./notion-import/datos.xlsx"]
}
]
}
There’s a full five-task example in the repository: docs/examples/notion-import.json.
File level
| Field | Type | Required | What it does |
|---|---|---|---|
version |
1 |
no | Format version. |
source |
text (≤ 50) | no (import) |
Default source of the tasks: notion, jira, trello… Part of the idempotency key. |
statusMap |
object | no | Status name in the source → status name in FXCode. |
priorityMap |
object | no | Same, for priorities. |
createMissingPeople |
boolean | no (false) |
Creates anyone who isn’t found as a person without an account. Without it, they’re only reported. |
tasks |
list | yes | 1 to 500 tasks. |
Each task
| Field | Type | Required | Notes |
|---|---|---|---|
externalId |
text (≤ 200) | yes | The task’s id in the source. Unique per source within the file. |
source |
text | no | Overrides the file’s source for this task. |
externalUrl |
http(s) URL | no | Link to the original task. |
title |
text (1–500) | yes | |
description |
Markdown (≤ 50,000) | no | Tables, quotes, lists and images. |
status |
text | no | Translated with statusMap; if it’s not there, it’s looked up as is. |
priority |
text | no | Translated with priorityMap. |
environment |
text (≤ 100) | no | E.g. production, staging. |
version |
text (≤ 100) | no | E.g. 1.4.0. |
tags |
list of texts | no | Up to 30. On re-import it replaces the tags. |
kind |
TASK | IDEA |
no | Task by default. |
assignee |
email or name | no | See people. |
requester |
email or name | no | Who requested it. |
createdAt, updatedAt, completedAt |
ISO 8601 date | no | 2025-02-10 or 2025-02-10T09:30:00Z. |
links |
list of { url, label? } |
no | Up to 50. Missing ones are added (by URL) and their label is updated. |
comments |
list | no | Up to 200. See comments. |
attachments |
list of paths | no | Up to 20 local files (≤ 10 MB each), relative to the JSON. |
Whatever the file doesn’t include is left untouched on update: a task re-imported without description keeps the one it had. Fields that aren’t part of the format are ignored and show up in the report.
Comments
| Field | Type | Required | Notes |
|---|---|---|---|
content |
Markdown (≤ 20,000) | yes | Can embed local images. |
author |
email or name | no | Kept even if they don’t have an account. |
createdAt |
ISO 8601 date | no | The original date. |
externalId |
text | no | The comment’s key. Without it, one is derived from author + date + content. |
attachments |
list of paths | no | Up to 10 files, attached to that comment. |
Comments are idempotent too: re-importing the same comment updates it. If you change its text in the source and it has no externalId, it comes in as a new one.
Statuses and priorities
statusMap translates the source’s names into yours, ignoring case and accents. A status that doesn’t map leaves the task without a status (or with the one it had, if it already existed) and shows up in the report. If a task arrives in a finished status without completedAt, it’s dated with its updatedAt.
Priorities work the same way. If your priority catalog is empty, the API creates Alta, Media and Baja on startup; you can also seed them yourself (it’s idempotent: if any priority already exists, it does nothing):
fxc settings priorities seed
fxc settings priorities list
People
assignee, requester and each comment’s author accept an email or an exact name (ignoring case and accents; first name or full name both work):
- By email, the whole people directory is searched.
- By name, among the project’s team and the people already attached to its tasks (so a person created in a previous import is recognized when you re-import). If two people share a name, nothing is guessed: it shows up as
ambiguous. - If no one is found and
createMissingPeopleistrue, they’re created as a person without an account. The task shows “Assigned to Leonardo” or “Requested by Alexander Navarro” just as it would with an account. - Without that flag, the field is left empty and the person shows up in the report as
missing.
If the person has an account, the task is also assigned to their user (it appears in “my tasks”).
Images and attachments
-
Embedded images: the CLI looks for
with local paths in descriptions and comments, uploads them as public images (png, jpg, webp, gif or avif, up to 5 MB) and rewrites the URL. Ones that are alreadyhttps://are left as they are. Paths with spaces work written as-is inside<…>or URL-encoded the way Notion exports them (image%203.png). -
Attachments: those in
attachmentsare uploaded to the task (or comment) after it’s imported. They’re private: they’re downloaded from the task through a temporary link. Any file type (docx, xlsx, pdf, zip…), up to 10 MB. -
Dry-run: uploads nothing. The report says how many images and attachments would be uploaded (
wouldUpload).
Re-importing without undoing your cleanup
Re-importing the same file updates existing tasks with everything in the file: title, description, status, priority, tags, assignee… If you edited tasks by hand after importing, limit what gets updated with --fields (or fields in the file). New tasks are still created in full.
# Add comments and original dates without touching titles, statuses or tags
fxc tasks import notion.json --project-id <id> --fields comments,dates --dry-run
fxc tasks import notion.json --project-id <id> --fields comments,dates
Fields: title, description, status, priority, environment, version, tags, kind, externalUrl, assignee, requester, dates (createdAt, updatedAt and completedAt), links, comments and attachments. With fields, the CLI only uploads the images and attachments that will actually be written.
Cleaning up after importing
An import rarely lands clean: duplicates, stale tasks, tags that no longer mean anything. Tidy it up from the CLI (or by selecting several tasks in the web app):
# Archive what's no longer relevant: out of lists, project progress and the MCP (nothing is deleted)
fxc tasks archive <id> <id> …
fxc tasks list --project-id <id> --archived # review what you archived (unarchive to undo)
# The same changes to many tasks at once
fxc tasks update --ids a,b,c --status "No Iniciada" --priority Media --add-tag migrated --remove-tag notion-old
fxc tasks update --ids a,b --assigned-person-id <personId> # a person without an account
# Merge duplicates into one: comments, attachments, links, notes and requests move to the target
fxc tasks merge <targetId> <duplicateId> <duplicateId>
# Order the work: relations, subtasks and the queue
fxc tasks link-task <id> --blocked-by <otherId>
fxc tasks update <id> --parent-id <parentId>
fxc tasks queue <id> --first
Re-importing respects your cleanup. An archived task stays archived when you re-import, and so does a merged one: the source task keeps its source + externalId, so the import updates it (archived) instead of creating it again. Combine that with --fields (for example --fields comments,dates) so a re-import doesn’t overwrite the statuses, tags or titles you fixed by hand. More in Kanban, queue and relations.
The report
The dry run and the import return the same thing (as is, with --json):
{
"dryRun": true,
"summary": { "total": 5, "create": 4, "update": 1 },
"tasks": [{ "externalId": "1a2b3c", "source": "notion", "action": "update", "title": "…", "taskId": "cm…" }],
"unmapped": {
"statuses": [{ "name": "Blocked", "count": 2 }],
"priorities": [],
"people": [{ "ref": "Leonardo", "count": 3, "resolution": "will-create" }],
"fields": [{ "field": "tasks[].Sprint", "count": 5 }]
},
"warnings": []
}
resolution is missing, ambiguous, will-create (dry run with createMissingPeople) or created. The CLI adds how many images and attachments it would upload or uploaded, and which ones don’t exist on disk.
The import sends no emails and no per-task notifications: when it finishes, open lists refresh just once.
Limits
| Limit | Value |
|---|---|
| Tasks per file | 500 |
| Title | 500 characters |
| Description | 50,000 characters (same as creating a task by hand) |
| Tags per task | 30 |
| Links per task | 50 |
| Comments per task | 200 (20,000 characters each) |
| Attachments | 20 per task, 10 per comment, 10 MB each |
statusMap / priorityMap entries |
100 |
More than 500 tasks? Split the file: since the key is source + externalId, you can import it in parts, in any order.
From Notion
Export the database as Markdown & CSV and turn each row into a task in the format above (a short script or your assistant can do it): the page id goes in externalId, its URL in externalUrl, the status column in status and the created-date column in createdAt. The export’s images end up in folders next to the Markdown: reference them with paths relative to the JSON and the CLI uploads them.