Source platforms have custom fields. Ekso has process fields. The migrator bridges the two with a migration.fields.yaml file you author by hand.
This page is the canonical format reference. Per-source pages link here for examples specific to each source.
What this solves
Every issue tracker has its own custom-field model:
- Jira uses
customfield_10026 numeric IDs that vary per tenant.
- Linear has a single first-class
estimate decimal.
- Azure DevOps uses
Custom.<FieldName> reference names.
- Zendesk has typed
ticket_field IDs.
- Gemini has named custom fields.
Without a field map, every source custom field falls through to DataItem.Meta losslessly — the data is preserved, but not searchable or filterable as a real field. With a field map, each source field maps to an Ekso process field that is searchable, filterable, and editable like any other field.
How auto-create works
When you run apply --field-map migration.fields.yaml, the Apply layer’s ProcessFieldApplier runs before any item writes:
- It reads your YAML.
- For each entry, it queries the destination
DataProcess (the one you passed to --process) for fields with that name.
- If the field exists, it’s a no-op.
- If the field doesn’t exist, it calls
POST /api/field to create it on the process. The field’s kind (text / decimal / picker / toggle) and any picker values come from the YAML.
- If field creation fails (permission denied, validation error),
apply exits with code 6 before writing any items.
This means after apply succeeds, your destination process has every field your YAML promised, populated with the source-platform values. You can audit and remove fields post-migration via the admin UI.
<source>:
<source-field-id>: { ekso: <EksoFieldName>, kind: <kind>, ...kind-specific }
Top-level keys are source names: jira, linear, devops, zendesk, gemini. The migrator reads only the section matching the source you’re migrating.
Process mapping (_process)
Most sources use a Type / Issue Type / Work Item Type to distinguish Bugs from Stories from Tasks. Ekso’s equivalent is the Process. The optional _process key under each source maps inbound type names to Ekso process slugs:
jira:
_process:
Bug: Defect
Story: Task
Task: Task
Epic: Enhancement
Sub-task: Task
devops:
_process:
Bug: Defect
Task: Task
User Story: New Feature
Issue: Task
When apply walks the cache, each item’s source type is looked up in _process to pick the destination process. If a type isn’t listed (or the source has no type concept at all, like Linear), the item falls back to the default process passed on the command line as --process.
Pre-flight validation. Before any items are written, apply verifies that every process slug it could need actually exists on the destination Ekso tenant — both the --process default and every value under any _process block. If any one is missing, apply aborts at the start with exit code 6 (validation). Half-applied migrations against a half-complete process catalogue are worse than no migration.
Field kinds
kind | Stores | Example source fields |
|---|
text | Free-form string | Jira sprint name, Linear project label |
decimal | Numeric value | Story points, estimates, custom durations |
picker | One value from a fixed list | Severity, Risk, Phase |
toggle | True / false | ”Has-PR-attached”, “Customer-facing” |
text example
jira:
customfield_10018: { ekso: Sprint, kind: text }
The Apply layer creates a text field named Sprint on the destination process if it doesn’t exist, then writes the source value into it on every item.
decimal example
jira:
customfield_10026: { ekso: StoryPoints, kind: decimal }
linear:
estimate: { ekso: StoryPoints, kind: decimal }
devops:
Microsoft.VSTS.Scheduling.StoryPoints: { ekso: StoryPoints, kind: decimal }
All three sources can map their estimate field to the same Ekso StoryPoints field — handy when you’re consolidating multiple sources into one Ekso tenant.
picker example
Picker fields are the most expressive. Map source values to Ekso picker values explicitly:
jira:
customfield_10031:
ekso: Severity
kind: picker
picker:
Critical: P0
High: P1
Medium: P2
Low: P3
The Apply layer creates a Severity picker field on the process with the four destination values (P0, P1, P2, P3), then maps each Jira severity to its Ekso counterpart on every item. Source values not in the picker mapping fall back to DataItem.Meta.
toggle example
zendesk:
has_attachments: { ekso: HasAttachments, kind: toggle }
Zendesk’s has_attachments boolean becomes an Ekso toggle field. Source values of true / 1 / "yes" / "true" (case-insensitive) become Ekso true; everything else becomes false.
Worked examples per source
Jira
jira:
customfield_10026: { ekso: StoryPoints, kind: decimal }
customfield_10018: { ekso: Sprint, kind: text }
customfield_10031:
ekso: Severity
kind: picker
picker:
Critical: P0
High: P1
Medium: P2
Low: P3
Discovering Jira custom-field IDs. Jira’s per-project custom field IDs are visible in the URL of the field’s edit page in admin (customfield_10026), or via the REST API:
Cloud authenticates with your email and API token against /rest/api/3; Data Center / Server authenticates with a Personal Access Token sent as a bearer header against /rest/api/2.
# Cloud (email + API token):
curl -u [email protected]:ATATT3... \
https://acme.atlassian.net/rest/api/3/field \
| jq '.[] | select(.custom == true) | {id, name}'
# Data Center / Server (Personal Access Token):
curl -H "Authorization: Bearer <your-PAT>" \
https://jira.mycompany.com/rest/api/2/field \
| jq '.[] | select(.custom == true) | {id, name}'
Multi-line commands are easier in Windows PowerShell ISE, and you don’t need to install jq.# Cloud (email + API token):
$cred = [Convert]::ToBase64String(
[Text.Encoding]::ASCII.GetBytes("<your user>:<your API token>"))
$fields = Invoke-RestMethod `
-Uri "https://<your site>.atlassian.net/rest/api/3/field" `
-Headers @{Authorization = "Basic $cred"}
$fields | Where-Object { $_.custom -eq $true } |
Select-Object id, name | Format-Table
# Data Center / Server (Personal Access Token):
$fields = Invoke-RestMethod `
-Uri "https://jira.mycompany.com/rest/api/2/field" `
-Headers @{Authorization = "Bearer <your PAT>"}
$fields | Where-Object { $_.custom -eq $true } |
Select-Object id, name | Format-Table
Linear
linear:
estimate: { ekso: Estimate, kind: decimal }
Linear’s first-class estimate field is the only thing most teams need to map. Custom fields beyond that are uncommon on Linear.
Azure DevOps
devops:
Microsoft.VSTS.Scheduling.StoryPoints: { ekso: StoryPoints, kind: decimal }
Microsoft.VSTS.Common.Severity:
ekso: Severity
kind: picker
picker:
"1 - Critical": P0
"2 - High": P1
"3 - Medium": P2
"4 - Low": P3
Custom.RootCause: { ekso: RootCause, kind: text }
Discovering DevOps reference names. From the work-item form: open any work item, click the field, the reference name shows in the field properties. Or via REST:
curl -u :PAT https://dev.azure.com/your-org/your-project/_apis/wit/fields?api-version=7.1 \
| jq '.value[] | {referenceName, name, type}'
Zendesk
zendesk:
"360001234567": { ekso: AffectedComponent, kind: text }
"360001234568":
ekso: CustomerTier
kind: picker
picker:
Free: tier-free
Pro: tier-pro
Ent: tier-ent
Zendesk ticket-field IDs are numeric strings — quote them in YAML so they’re treated as keys, not numbers.
Discovering Zendesk ticket field IDs:
curl -u [email protected]/token:API_TOKEN \
https://acme.zendesk.com/api/v2/ticket_fields.json \
| jq '.ticket_fields[] | {id, title, type}'
Gemini
gemini:
PullRequestUrl: { ekso: PullRequestUrl, kind: text }
Severity:
ekso: Severity
kind: picker
picker:
Critical: P0
High: P1
Medium: P2
Low: P3
Gemini custom fields are accessed by name. SQL mode reads them from dbo.IssueCustomFields; API mode pulls them from the issue payload.
Without a field map
You can run apply without --field-map. Source custom fields fall through to DataItem.Meta losslessly. Each source field becomes a Meta key prefixed with the source name (e.g. Meta.jira_customfield_10026 = "5"). This is fine for archival migrations where the destination process schema doesn’t matter. For active use, write the YAML.
What gets created on the destination process
After apply runs with a field map, your destination process has:
- A new field for every entry in the YAML (created if it didn’t already exist).
- Every item populated with values from the source field.
The created fields appear under the process’s field list in the admin UI. You can rename, reorder, or delete them after the fact — the migration doesn’t lock anything.
Failure modes
| Situation | Behaviour |
|---|
| YAML references a kind the migrator doesn’t support | exit 2 (usage error) before any work happens |
picker field with no picker: value list | exit 6 (validation) — picker fields need values |
| Field creation fails (permission denied) | exit 4 (forbidden), no items written |
| Source value doesn’t match any picker value | the source value is preserved in Meta, the item field is left null, and apply continues |
| Decimal field gets a non-numeric source value | Meta fallback for that item, warn at end |
Where to next