Status: Research / Design Date: 2026-05-17 UTC Author: feature-developer agent (dispatched from #723) Parent epic: #707 (FreeScout productionization) Related: #651, #357 (superseded by #1147), #631, #1147, #1148 Category: not-blocking-launch — workflow enhancement
When a FreeScout ticket meets certain criteria (bug report, feature request, or ops incident), automatically create a corresponding GitHub issue so engineering work is tracked in the right system. Optionally, when a GitHub issue is labeled with a support-relevant tag, open or update a linked FreeScout ticket.
Mental model: FreeScout owns SLA + customer interaction; GitHub owns engineering work. The link between them is a stable reference in both objects — not live sync state.
feat(console/status): "Investigate" button on degraded tiles — auto-file FreeScout ticket
creates a FreeScout ticket with a workflow UUID and a back-link to the console tile. It does NOT create a GitHub issue, and it does NOT link the FreeScout ticket to a GitHub issue number.
scope (bidirectional FreeScout ↔ console sync) is explicitly deferred; no card exists for that yet.
incident_severity custom field on FreeScout tickets. This field drives which
non-OPERATIONAL state the status-state machine enters. It has no GitHub-issue
linking logic.
The support.raxx.app portal sub-cards cover the customer-facing ticket experience.
None of the nine sub-cards (#1004–#1012) include FreeScout → GitHub issue creation.
The support.raxx.app design doc (docs/architecture/support-raxx-app.md) confirms
no GitHub issue linking is in scope for the portal.
No existing card covers FreeScout-ticket-triggered GitHub issue creation or GitHub- issue-triggered FreeScout-ticket creation. The work described below is net-new scope.
Per project_freescout_api_limits.md, the scriptable FreeScout surfaces are:
| Operation | Mechanism |
|---|---|
| Read conversations, threads, customers | GET /api/conversations, GET /api/conversations/{id}/threads |
| Create conversation (ticket) | POST /api/conversations |
| Update conversation status/tags | PUT /api/conversations/{id} |
| Add internal note to conversation | POST /api/conversations/{id}/threads with type: note |
| Receive event notifications | Outbound webhook (HMAC-SHA256 signed) on conversation events |
| Mailbox management | UI-only — not scriptable |
| Auto-reply rules | UI-only — not scriptable |
Key constraint: FreeScout custom field values are readable/writable via the
conversations API. Once the component_tag and incident_severity fields are
provisioned (#605, #631), a new github_issue_url custom field can be added to hold
the back-reference — no architectural change needed, just a new field provisioned in
the FreeScout admin UI and documented in the runbook.
FreeScout fires a webhook on conversation.created. Raptor (or a lightweight bridge)
receives the webhook, evaluates criteria, and calls the GitHub API to open an issue.
A ticket triggers GitHub issue creation when all of the following are true:
tags include bug, feature_request, or auto:status (the tag
applied by #1147's Investigate flow).ops@raxx.app or support@raxx.app mailbox (not an
internal-only mailbox).github_issue_url custom field on the conversation — empty means not yet linked).Keyword scanning of the ticket body is an optional secondary filter but should not be the primary trigger — tags are operator-controlled and more reliable.
The lightest viable path: a new Raptor route that is already a known-good FreeScout
webhook receiver (the freescout_client.py pattern from #1147 is reusable).
FreeScout (tickets.raxx.app)
→ POST /api/webhooks/freescout/support [existing, from #1007]
or
→ POST /api/webhooks/freescout/ops [new route, for ops mailbox events]
↓
Raptor: evaluate criteria
↓ (if criteria met)
GitHub REST API: POST /repos/raxx-app/TradeMasterAPI/issues
↓
FreeScout API: PUT /api/conversations/{id}
{ custom_fields: { github_issue_url: "https://github.com/..." } }
+ POST internal note thread: "GitHub issue #N filed: <url>"
X-FreeScout-Signature HMAC-SHA256 (existing pattern from
status-raxx-app.md §9).raxx-app/TradeMasterAPI repo with issues: write. Alternatively, a GitHub App
installation token for raxx-dev-bot — preferred because it rotates automatically.
Token stored in Infisical at /raptor/prod/github-issue-bot-token
(or /raptor/prod/github-app-private-key + installation ID if using GH App).FREESCOUT_API_KEY already in Raptor env.GET /api/conversations/{id} and
checks custom_fields.github_issue_url. If non-empty, skip creation and return
HTTP 200 (idempotent).custom_fields.github_issue_url on the FreeScout conversation. Subsequent
webhook deliveries (FreeScout retries) hit the idempotency check and no-op.A GitHub Actions workflow fires when an issue is labeled. Relevant labels:
| GitHub label | FreeScout action |
|---|---|
customer-reported |
Create or update a FreeScout ticket in the ops@ mailbox |
regression |
Create FreeScout ticket tagged bug + regression |
needs-customer-confirm |
Add an internal note to the linked FreeScout ticket asking for customer confirmation |
A GitHub Actions workflow in .github/workflows/freescout-sync.yml:
on:
issues:
types: [labeled]
The workflow step calls POST /api/conversations via curl or a lightweight Python
script, using FREESCOUT_API_URL + FREESCOUT_API_KEY from GitHub Actions secrets
(sourced from Infisical at workflow dispatch time via the existing vault-read pattern).
gh issue comment --body "FreeScout ticket: <url>") with a sentinel prefix
[freescout-autolink]. On any re-trigger, the workflow checks for an existing
comment with that prefix via gh issue view --json comments and skips creation if
found.auto-linked tag that triggers
further processing in Direction A). Alternatively, a _from_github_action tag can
be applied to FreeScout tickets created by Direction B — Direction A's webhook
handler can then explicitly skip those.| Source | Trigger | Criteria | Action |
|---|---|---|---|
| FreeScout | conversation.created |
tag in {bug, feature_request, auto:status} AND github_issue_url empty |
Create GitHub issue; write back URL |
| FreeScout | conversation.status_changed to closed |
github_issue_url non-empty |
Add GH issue comment "FreeScout ticket resolved"; optionally close GH issue |
| GitHub | issues.labeled with customer-reported |
No [freescout-autolink] comment exists |
Create FreeScout ticket; write back GH comment |
| GitHub | issues.labeled with needs-customer-confirm |
[freescout-autolink] comment exists (ticket already linked) |
POST internal note on existing FreeScout ticket |
All round-trips are guarded by a stable reference written to both objects before any further event can fire:
FreeScout conversation → custom field: github_issue_url = "https://github.com/..."
GitHub issue → comment body starts with "[freescout-autolink] ..."
Rules:
1. Direction A handler checks github_issue_url before any GitHub API call.
2. Direction B workflow checks for [freescout-autolink] comment before any FreeScout
API call.
3. FreeScout tickets created by Direction B carry tag _from_github — Direction A
webhook handler skips any ticket bearing that tag.
4. GitHub issues created by Direction A are labeled freescout-linked by the GH API
call — Direction B workflow skips issues already bearing that label.
This gives a two-lock system. Either lock independently prevents a runaway loop.
| Secret | Where stored | Consumer |
|---|---|---|
FREESCOUT_API_KEY |
Infisical /raptor/prod/freescout-api-key; Heroku config var |
Raptor (existing) |
FREESCOUT_WEBHOOK_SECRET |
Infisical /raptor/prod/freescout-ops-webhook-secret |
Raptor webhook receiver |
GITHUB_ISSUE_BOT_TOKEN |
Infisical /raptor/prod/github-issue-bot-token |
Raptor (Direction A outbound) |
FREESCOUT_API_KEY (GH Actions) |
GitHub Actions secret FREESCOUT_API_KEY (sourced from Infisical at dispatch) |
GH Actions workflow (Direction B) |
The GitHub token for Raptor should be a fine-grained PAT for raxx-app/TradeMasterAPI
with scope issues:write only. If raxx-dev-bot GitHub App gains issues:write
permission on the repo, use an installation token instead — avoids manual rotation.
Before any sub-card can ship:
github_issue_url custom field provisioned in FreeScout admin UI (small ops task;
add to the #605/#631 runbook pattern — no new card needed if the runbook is updated
in the same PR as the first Direction A card).POST /api/webhooks/freescout/support) is planned under #1007. The ops-mailbox
variant is a new route; it can share the same handler logic.auto:status tag applied by #1147's Investigate flow is already in
production before Direction A is enabled, so the first real trigger is an ops
ticket from the Investigate button.The following five atomic cards cover the full scope. They are sequenced; each is a valid stopping point.
ops(support): add github_issue_url custom field to FreeScout + runbook (size:xs)Provision a fourth FreeScout custom field github_issue_url (type: text / URL).
Update docs/runbooks/freescout-status-fields.md (the runbook from #605/#631) to
document the field slug, type, and purpose. Confirm field slug via FreeScout API.
No code changes. Depends on: #605 (FreeScout custom fields runbook) being merged.
feat(raptor): FreeScout ops-mailbox webhook receiver + GitHub issue creation (Direction A) (size:s)New Raptor route: POST /api/webhooks/freescout/ops. On conversation.created:
evaluate criteria, call GitHub REST API to create an issue, write github_issue_url
back to FreeScout, post internal note. Idempotency via github_issue_url check.
HMAC signature verification. FLAG_FREESCOUT_GITHUB_AUTOLINK gates the route (default
off). pytest coverage: happy path, idempotency re-trigger, criteria-not-met no-op,
invalid signature 401.
feat(raptor): FreeScout ticket-close → GitHub issue comment (Direction A, close event) (size:xs)Extend the ops-mailbox webhook handler to process conversation.status_changed to
closed. If github_issue_url is set on the ticket, post a GitHub issue comment:
"FreeScout ticket resolved (UTC timestamp)." Does not close the GitHub issue — that
is an engineer decision. Same flag as Card 2. pytest: close event with linked issue,
close event without linked issue (no-op).
feat(ci): GitHub Actions workflow — issue-labeled → FreeScout ticket (Direction B) (size:s).github/workflows/freescout-sync.yml triggered on issues.labeled. On
customer-reported or regression labels: check for existing [freescout-autolink]
comment, create FreeScout ticket in ops mailbox if not found, post sentinel comment.
On needs-customer-confirm + existing link: POST internal note to the linked ticket.
Auth: FREESCOUT_API_KEY GitHub Actions secret. Loop prevention: _from_github tag
on created tickets. Test: workflow unit test via act or equivalent; AC checklist
in the card.
feat(console): FreeScout ticket detail — show linked GitHub issue badge (size:xs)In the console ticket thread view, if custom_fields.github_issue_url is present,
render a small badge linking to the GitHub issue. Read-only display; no console-side
write logic. Depends on Card 2 having shipped (so real data exists). Feature flag:
same FLAG_FREESCOUT_GITHUB_AUTOLINK. Jest test: badge renders when URL present,
badge absent when field empty.
support.raxx.app: internal notes and
github_issue_url are operator-only fields; per support-raxx-app.md §4, internal
fields are stripped before any customer-facing response.OQ-1 — GH App vs. fine-grained PAT for Direction A:
raxx-dev-bot GitHub App installation token is preferred (auto-rotating). Confirm
whether raxx-dev-bot has issues:write on raxx-app/TradeMasterAPI, or whether
a separate fine-grained PAT should be provisioned. Blocks Card 2.
OQ-2 — Criteria for auto-create: tag-based only, or also keyword scan?
Tag-based is more reliable (operator-controlled). Keyword scanning (bug, crash,
broken, feature request in subject/body) is noisier but catches untagged tickets.
Recommend tag-only for v1, with keyword scan as an opt-in config option in Card 2.
Does not block writing Card 2 — it is an implementation choice, not an architecture
question. Needs confirmation before flag flip.
OQ-3 — Scope of Direction B labels:
The proposed labels (customer-reported, regression, needs-customer-confirm) are
new labels that do not yet exist in the repo. Confirm the label set before Card 4 is
filed. Does not block writing the workflow — labels can be created as part of Card 4.