Jira Cloud
Promote a SessionFS ticket to a Jira Cloud issue. Promotion itself is one way: SessionFS creates or updates the Jira issue from the ticket and reads its status on demand, never touching status or assignee. Since v0.28.0 a publish can also place the issue in a sprint you pick, set its parent from the ticket’s parent, and carry the ticket’s due date and estimate.
With two-way sync enabled (see below), a linked ticket can also mirror changes in both directions: status transitions, field edits and comments can flow back into SessionFS through a signed inbound webhook, and SessionFS status changes, opted-in comments and completion evidence can be pushed to Jira. The ticket stays the source of truth — inbound transitions go through the same ticket FSM as any other status change and are never forced past a legal step.
Available on the Enterprise plan, from the dashboard, the CLI and the API. Added in v0.23.0.
What it does
Section titled “What it does”Anyone who can write to a ticket can:
- Preview a Jira story built from the ticket, without sending anything to Jira. The preview is editable.
- Publish it. The first publish creates the issue; later publishes update its summary and description.
- Check status: the linked issue key, URL and current Jira status (cached for 60 seconds).
- Unlink it (project admins). The Jira issue itself is left alone.
Anyone who can edit a ticket can also:
- Link an existing issue instead of creating one (v0.27.0). See Link an existing issue.
- Update from Jira: replace a linked ticket’s title and description with the issue’s.
A ticket links to at most one Jira issue, so publishing again updates the same issue instead of creating a duplicate. Publishing is safe to retry: each issue carries a sessionfs-<ticket_id> label, and whenever a create’s outcome is unknown (Jira didn’t answer in time, the connection dropped, or the publish was interrupted) the next publish finds and adopts the issue by that label instead of creating a second one. It also creates into the same Jira project the interrupted publish chose, even if the mapping changed in between. Two publishes of the same ticket at once can’t both create an issue, and a description over Jira’s 32,000-character limit is refused before anything is sent.
-
Connect the org (org owners and admins): on the Organization page’s Integrations tab, choose Connect on the Jira row, or:
Terminal window sfs org jira connect \--site-url https://acme.atlassian.net \--email jira@acme.com \--template agileThe credentials are checked against Jira first. You’ll be prompted for an Atlassian API token, which is encrypted at rest and never returned.
-
Map projects to Jira projects under Manage → Mappings, or with
PUT /api/v1/orgs/{org_id}/integrations/jira/mappings/{project_id}. A mapping sets the Jira project key, issue type (defaultStory), an optional template override and an optional “done” status name. Both pickers in the Add mapping dialog are searchable; typing part of a Jira project key or name searches the whole Jira site. -
Promote tickets. On a ticket, Promote to Jira opens the preview; a linked ticket shows its issue key and an Update Jira button. From the CLI:
Terminal window sfs ticket jira preview tk_abc123 --out preview.jsonsfs ticket jira publish tk_abc123 --from preview.jsonsfs ticket jira status tk_abc123sfs ticket jira unlink tk_abc123 --confirm
The Integrations tab also shows health (sfs org jira health); see In the dashboard.
Link an existing issue
Section titled “Link an existing issue”When the work is already tracked in Jira, link the ticket to that issue instead of publishing a new one. On a ticket, Link existing issue takes an issue key such as PAY-142, or call POST /api/v1/projects/{pid}/tickets/{tid}/jira/link with {"issue_key": "PAY-142"}. Linking needs edit rights on the ticket. SessionFS reads the issue to check that it exists, belongs to the Jira project mapped to this project and isn’t already linked to another ticket, then records the link. Nothing is sent to Jira.
A linked issue that has never been published from SessionFS can differ from the ticket, so you choose which side wins:
- Overwrite KEY in Jira publishes the ticket over the issue, replacing its summary and description. Publishing such an issue is refused with
409 jira_overwrite_requireduntil you confirm the overwrite: the dashboard asks first, the CLI takes--overwrite-existing, and the API takes"overwrite_existing": trueon publish. After that first publish, later publishes update the issue as usual. - Update from Jira replaces the ticket’s title and description with the issue’s summary and description (
sfs ticket jira pull, orPOST /api/v1/projects/{pid}/tickets/{tid}/jira/pullwith{"confirm": true}). An empty Jira summary or description leaves that ticket field unchanged, the pulled text goes through your org’s DLP policy, and nothing is pushed back to Jira. Pulling needs a user key and edit rights on the ticket; the change is recorded in the ticket’s edit history like any other edit.
sfs ticket jira publish tk_abc123 --from preview.json --overwrite-existingsfs ticket jira pull tk_abc123The session helper can propose the same link and either overwrite for you to confirm.
Sprints, parents and scheduling
Section titled “Sprints, parents and scheduling”Added in v0.28.0.
Agile fields
Section titled “Agile fields”Jira Cloud stores sprints and, on older projects, epic links in custom fields whose ids differ from site to site. SessionFS detects both when an org first connects, so most orgs never have to look them up. An org owner or admin can see and change them under Manage → Connection → Agile fields: Detect fields asks the site again and fills in what it finds for you to review and save, or you can enter an id like customfield_10020 by hand. A connection made before v0.28.0 has no agile fields yet: run Detect fields once and save. The same overrides can be set with sprint_field_id and epic_link_field_id on PATCH /api/v1/orgs/{org_id}/integrations/jira/sync. The API token isn’t needed. A field you leave out keeps its value and an explicit null clears it. A manual override survives re-saving the connection. Reconnecting to a different Jira site resets both fields to what that site reports, since ids from the old site may not exist there. POST /api/v1/orgs/{org_id}/jira/fields/detect runs detection again and shows the result without saving it.
Pick a sprint
Section titled “Pick a sprint”The publish dialog lists the mapped Jira project’s active and future sprints, active first, with their dates in your own time zone (GET /api/v1/projects/{pid}/jira/sprints, up to 100). Choose one to place the issue in it: a new issue gets the sprint on create, and an existing issue is moved into it. When the site has no sprint field, or Jira Software isn’t enabled, the dialog says so and publishes without a sprint. The API takes sprint_id on publish.
Parent issue
Section titled “Parent issue”When a ticket has a parent (an Issue it rolls up to) and that parent is linked to a Jira issue, publishing sets the Jira parent, falling back to the epic-link field on projects that still use one. If the parent isn’t linked to Jira yet, the issue is published without a parent and a warning tells you to publish or link the parent first.
A linked ticket shows its Jira sprint and parent next to the issue key.
Due date and estimate
Section titled “Due date and estimate”A ticket’s due date and estimate (see Project Detail) go to Jira as the issue’s due date and original estimate. Update from Jira brings them back when Jira has them. With two-way status sync on, a due date changed in Jira updates the ticket too, recorded in its history as a change made by Jira.
Publish warnings
Section titled “Publish warnings”A publish never fails because Jira refused an optional field. Jira leaves the field out, the issue is published, and the dialog explains what happened in plain language, for example “The due date wasn’t set: the field isn’t on this issue type’s screen in Jira” or “The issue was published, but Jira didn’t move it into the chosen sprint.” The API returns the same as warnings codes such as jira_sprint_not_set:move_failed, jira_parent_not_linked or jira_field_not_on_screen:duedate.
In the dashboard
Section titled “In the dashboard”Once connected, the Jira row on the Integrations tab shows the site and a one-line health summary that only names problems (for example “1 unmapped project · 1 push failure”). Manage opens the Jira settings in sections: Overview (health first, then sync and mapping status at a glance), Connection, Mappings, Templates, Sync and Activity. Each health problem links to the section that fixes it, and a healthy integration shows a single “All good” line.
The Connection section is a read-only summary (site, account, last four characters of the token, verified date, default template). Edit connection opens the form, which always asks for the API token again so it can be re-encrypted.
Two-way sync
Section titled “Two-way sync”Two-way sync is off by default. An org owner or admin turns it on under
Manage → Sync in the Jira settings, or with
PATCH /api/v1/orgs/{org_id}/integrations/jira/sync and any of
sync_status, sync_comments (off, inbound, outbound or both) and
status_map. The PATCH merges: fields you leave out keep their value, and you
don’t re-enter the API token. The connection PUT merges the same way, so
reconnecting or rotating the token never resets sync.
Every sync action is recorded in the tracker_sync_events ledger. The health
response reports last_inbound_at, last_synced_at, pending_sync_events,
failed_sync_events_24h (failed or refused events, in either direction) and
webhook_auth_failures_24h. The dashboard lists recent events under
Manage → Activity.
In the Sync section the status map is a two-column table: each SessionFS status and the Jira transition it pushes (blank means “Not synced”), next to each Jira status category and what it does to the ticket. You can edit and save the map while two-way status sync is off; it applies once sync is on.
Inbound webhook
Section titled “Inbound webhook”In Jira Cloud, create an admin webhook for the jira:issue_updated and
comment_created events, pointing at:
POST /api/v1/webhooks/jira/{org_id}The Sync section shows the full URL for your org, with a Copy button. The webhook lives under
/api/v1 so the same URL works on the hosted service and on a Helm install,
whose dashboard proxy forwards only /api, /mcp and /health.
Generate a secret in the Sync section, or with
POST /api/v1/orgs/{org_id}/integrations/jira/webhook-secret. The secret is
returned once. The server stores it encrypted, because it has to
recompute each delivery’s signature; only its last 4 characters are ever shown
again. The dashboard shows it once, with a Copy button. Paste it into Jira’s
webhook settings. Jira signs each delivery
with X-Hub-Signature: sha256=<hex> over the raw body, and SessionFS checks it
in constant time. It fails closed:
- the org has no Jira connection →
404; - no secret has been generated →
503; - the signature is missing or wrong →
401. Rejected deliveries are counted in a rolling 24-hour window on the connection, and at most one audit row per org per hour records them.
The body is read as a stream and refused with 413 once it passes 1 MiB, with
or without a Content-Length. A delivery for an issue with no SessionFS link
on the connection’s site is ignored with 200. If the org’s plan no longer
includes the Jira integration, the delivery is ignored and an error event is
recorded.
Idempotency. Jira’s X-Atlassian-Webhook-Identifier header is the
delivery key, so a retry is answered {"status": "duplicate"}. Without that
header the key falls back to Jira’s comment or changelog id, prefixed with the
event type. That way a comment_created delivery is never mistaken for the
jira:issue_updated delivery Jira sends for the same comment.
Status. Only an issue_updated delivery whose changelog contains a
status change can move a ticket. Every delivery carries the issue’s current
status, but an edit that doesn’t change it never re-applies it. With
sync_status on, the new status category is looked up in the status map and
the action goes through the same ticket transitions as the ticket routes. Those
keep the FSM checks, resolved_at, dependent unblocking and enrichment, and
watcher notifications. The org’s admin configured the map, so an inbound
transition acts on the org’s behalf, attributed to “jira”. It still can’t do
anything the FSM forbids: only Tasks in review resolve to done, only Issues
in progress close. A refused transition is recorded as an error event and
counted in health; it is never forced.
Echoes. A delivery is dropped as an echo only when it matches something
SessionFS actually pushed. A status change is an echo when SessionFS pushed a
transition of the same name, for the same target status, in the last 5
minutes. A comment is an echo when its Jira id was recorded when SessionFS
posted it. As a fallback, for a post whose id was never recorded, it is also
an echo when it ends with the outbox reference SessionFS adds to every comment
it posts, and it comes from the connection’s own account (or that account
isn’t known yet). A reply that quotes or pastes a reference is still mirrored. SessionFS never pushes field edits, so those always apply.
The connection’s own Atlassian accountId is only a hint: it can rule an echo
out (a different user made the change), never in.
A dedicated Jira integration account is recommended for the connection, so SessionFS’s own changes are easy to tell apart in Jira. A personal account works too: your own transitions, edits and comments in Jira still sync back.
Field edits. A change to summary, description or priority posts a
system comment on the ticket: Edited in Jira by <name>: <field> changed. It
includes at most a 500-character excerpt, which is DLP-redacted before it is
cut so a secret can’t survive truncation. Only the redacted excerpt is stored.
Changes to other fields are ignored.
Comments. When sync_comments is inbound or both, a comment_created
delivery becomes a ticket comment with source=jira, and it is never trusted.
The text is DLP-redacted and capped at 10,000 characters. The author is shown
as <name> (Jira), with the name DLP-redacted and shortened to fit.
Outbound pushes
Section titled “Outbound pushes”A status change on a linked ticket queues an outbound push. A queued push
becomes a pending row in the sync ledger, written in the same transaction as
the ticket change. After the request commits, a background task delivers it,
so a Jira call never runs inside the ticket request and a Jira outage never
fails it. If the task is lost (for example an instance shutdown), the sweep
delivers the row later.
Only an actor with edit standing on the project, or a service key within its org and project allowlist, drives Jira. A reader’s lifecycle moves on tickets they created stay inside SessionFS.
Each Jira call has a 30-second timeout and one retry after a timeout. A failed
delivery is retried with exponential backoff (1, 4, 16 and 64 minutes) through
next_attempt_at. After 5 attempts the row is marked exhausted. A row is
skipped with a reason when it no longer applies: the ticket was unlinked,
sync was turned off, the status is unmapped, the row was superseded by a newer
status, or the plan lost the integration.
Comments go to Jira only when the org’s sync_comments is outbound or
both:
- Send to Jira on a comment is honoured for editors. A reader’s flag is
ignored. When an editor ticks it but the ticket is not linked or outbound
comment sync is off, the comment is refused with
409 jira_comment_sync_offand nothing is written. - A trusted reviewer’s verdict is forwarded only when it is
VERIFIED-CLEAN, as read by the review-state parser.CHANGES-REQUESTEDand plain comments are never forwarded.
Outbound comments carry a “via SessionFS —” prefix, are DLP-redacted and are
cut to fit the ADF budget. Every comment SessionFS posts (including completion
evidence) ends with a [SessionFS ref tse_…] line naming its outbox row.
Before posting, SessionFS looks for that reference among the issue’s 20 newest
comments. A post that landed before a client timeout, or before a lost commit,
is therefore never posted twice. If the lookup itself fails, SessionFS posts
anyway rather than fail the delivery.
Transitions are idempotent in the same way. The target status comes from the transition itself, because Jira reports which status each transition leads to. That works even when the names differ, such as “Resolve” leading to “Resolved”. SessionFS records the target before posting. If Jira no longer offers the transition, or a request timed out, SessionFS re-reads the issue. When the issue is already at the target status, the push counts as delivered instead of failing with “no transition named …”.
POST /api/v1/admin/sweeps/jira-sync needs a platform-admin user key or a
platform_sweeps:run service key. It does two things:
- It delivers due rows:
pending, fewer than 5 attempts,next_attempt_atreached. Terminal rows never block new failures. - It deletes finished ledger rows older than 90 days.
Each row is claimed, delivered and committed on its own, and an exception on
one row is recorded on that row. Run it every few minutes. The optional body
{"limit": 1-1000, "budget_seconds": 1-120} bounds a run (defaults 100 and
50).
Status map
Section titled “Status map”status_map maps both directions:
transitions: SessionFS status → Jira transition name (outbound). The keys areopen,in_progress,blocked,review,done,cancelledandclosed.categories: Jira status category (new,indeterminate,done) → SessionFS action (inbound):resolve,start,reopenornone.
"none", an empty string or null means “not synced”. The defaults cover
only what a stock Jira workflow has. Outbound, open → “To Do”,
in_progress → “In Progress”, and done and closed → “Done”; blocked,
review and cancelled are not synced. Inbound, only Done acts (resolve).
Moving an issue back to To Do or In Progress in Jira never rewinds SessionFS
unless you map it. Unknown statuses, categories or actions are rejected with
422. A transition your Jira workflow doesn’t offer is recorded as a failed
event.
Completion evidence
Section titled “Completion evidence”When a linked ticket reaches its completed status (done for a Task, closed
for an Issue) with status sync on, SessionFS posts one evidence comment to
the Jira issue. It contains:
- the latest agent-run verdict, the persona and a trusted-reviewer badge;
- per-file line counts from the sessions the ticket links (its resolver session and related sessions, in the ticket’s own project and not deleted), capped at 20 files;
- the knowledge claims the ticket records (count plus up to 5 titles; live claims of the ticket’s project only);
- links to the ticket and sessions when
SFS_DASHBOARD_URLis set.
The comment is DLP-redacted and fits the ADF budget. It posts once per completion: the evidence row is claimed under a unique key on the link and the completion time before anything is sent. Concurrent jobs can’t double-post, and reopening and re-resolving a ticket posts new evidence. Evidence is its own outbox row, so a failure building it can’t undo the status push. The failure is recorded and retried.
Templates
Section titled “Templates”Five built-in templates: Agile (agile), Compliance (compliance), SRE (sre), Bug report (bug) and Design/UX (design). The org picks a default when connecting, and a project mapping can override it.
Each template is a list of sections:
- Copied word for word from the ticket: acceptance criteria, completion notes and linked sessions. These never come from a model.
- Reworded sections are built from the ticket’s fields and, when the org or user has an LLM key, reshaped by that model. Reworded sections can carry a guidance string that is passed to the model alongside the source fields.
Org admins can also create custom templates (up to 50 active per org) under Manage → Templates, each with a unique slug and up to 12 sections. Copied-word-for-word sections can use acceptance criteria, completion notes or linked sessions; reworded sections can use any field (title, description, acceptance_criteria, priority, kind, persona, completion_notes, linked_sessions). Reworded sections can carry guidance: a hint to the model about tone or emphasis, not a source. It can’t add content, because every reworded paragraph still has to cite the ticket. The editor previews a draft against a real ticket from a mapped project before you save, in a side pane next to the sections. Since v0.28.0 every mode and source field has a tooltip explaining it, Copy from… starts a new template from a built-in or an existing custom one, and the project picker for the preview is searchable.
Editing a custom template creates a new version; if another admin saved in the meantime you’re asked to reload instead of overwriting their change. Archiving stops a template being picked as a new choice, but mappings, the org default and published tickets already using it keep working, and the health panel lists them. You can unarchive it later. Archived templates keep their slug reserved, so a new template can’t reuse it.
The template for a ticket comes from the choice in the promotion dialog, then the project mapping, then the connection default. The dialog starts on the template the ticket was last published with. A published ticket keeps that template even after it is archived; other tickets can’t pick it. Publishing uses exactly the template version you previewed: if the template changed in between, you’re asked to preview again. Each published link records the slug and version it used.
Every published story ends with a footer such as Generated by SessionFS · template: Design Lite v3. When the dashboard URL is configured it also links back to the source ticket.
Every reworded paragraph has to cite a real part of the ticket (a description line range, an acceptance-criteria entry, or a field such as title or priority). A paragraph whose citation doesn’t resolve is dropped, and a section with nothing left falls back to the plain draft. With no LLM key, every section uses the plain draft.
Security
Section titled “Security”- The Atlassian API token is encrypted at rest and never returned or logged.
- Site URLs must be
https://<name>.atlassian.net. Redirects aren’t followed, requests time out after 20 seconds, and responses are capped at 2 MiB. - Ticket text is redacted for secrets before any of it reaches a model, even when the org has no DLP policy.
- Org routes check membership and the org’s plan; ticket routes check the project’s org and use the existing
tickets:read/tickets:writescopes. - If the org reconnects to a different Jira site, existing links are reported as stale (
409 jira_link_stale) and must be unlinked before publishing again.
| Method | Path | Purpose |
|---|---|---|
| GET / PUT / DELETE | /api/v1/orgs/{org_id}/integrations/jira |
Read, save or remove the connection |
| GET | /api/v1/orgs/{org_id}/integrations/jira/projects |
Jira projects (?q= to search, ?limit= up to 100) |
| GET / PUT / DELETE | /api/v1/orgs/{org_id}/integrations/jira/mappings[/{project_id}] |
Read, save or remove mappings |
| GET | /api/v1/orgs/{org_id}/integrations/jira/health |
Unmapped projects, failures, stale links |
| PATCH | /api/v1/orgs/{org_id}/integrations/jira/sync |
Change two-way sync settings and the sprint/epic-link field overrides (merge; no token needed) |
| POST | /api/v1/orgs/{org_id}/integrations/jira/webhook-secret |
Generate/rotate the inbound webhook secret (returned once; stored encrypted, only the last 4 characters are shown again) |
| GET | /api/v1/orgs/{org_id}/integrations/jira/sync-events |
Recent two-way sync events (admin). ?direction=inbound|outbound, ?limit= up to 200 |
| POST | /api/v1/orgs/{org_id}/jira/fields/detect |
Detect the site’s sprint and epic-link field ids without saving them (v0.28.0) |
| GET | /api/v1/projects/{pid}/jira/sprints |
Active and future sprints of the mapped Jira project (v0.28.0) |
| POST | /api/v1/projects/{pid}/tickets/{tid}/jira/preview |
Draft the story |
| POST | /api/v1/projects/{pid}/tickets/{tid}/jira/publish |
Create or update the issue (optional sprint_id; response warnings) |
| GET | /api/v1/projects/{pid}/tickets/{tid}/jira |
Linked issue and cached status |
| DELETE | /api/v1/projects/{pid}/tickets/{tid}/jira |
Unlink (project admin) |
| POST | /api/v1/projects/{pid}/tickets/{tid}/jira/link |
Link the ticket to an existing Jira issue (issue_key; v0.27.0) |
| POST | /api/v1/projects/{pid}/tickets/{tid}/jira/pull |
Overwrite the ticket’s title and description from the linked issue (confirm: true; user keys only; v0.27.0) |
| POST | /api/v1/webhooks/jira/{org_id} |
Jira Cloud inbound webhook (HMAC-signed) for two-way sync |
| POST | /api/v1/admin/sweeps/jira-sync |
Deliver due outbound sync events and expire old ones (platform_sweeps:run) |
| GET | /api/v1/projects/{pid}/tickets/{tid}/jira-sync-events |
Sync events for a linked ticket |
GET /api/v1/projects/{pid}/tickets/{tid} and the get_ticket MCP tool include a jira object when the ticket has been promoted.
Limitations
Section titled “Limitations”- Jira Cloud is the only tracker so far.
- Promotion failures are reported when you publish; there is no retry for a failed publish/update of a linked issue — it is listed in the health view until the next successful publish. Two-way outbound sync, by contrast, is queued durably and retried by the admin sweep (up to 5 attempts with exponential backoff).
- Two-way sync is a SessionFS-side mirror, not a Jira plugin: status changes made in Jira arrive via webhook, so a Jira outage or misconfigured secret means inbound events are missed rather than queued for later replay. Events older than 90 days are expired from the sync ledger.