Developer guide¶
Last updated: 2026-09-17
The technical reference for changing Beacon itself: the Swift package, the page, the pickup and the workflows. To add Beacon to an app, read the options reference and GitHub setup instead. What Beacon collects is in What is collected, and testers have their own page. What is proven and what is not is in STATUS.md.
Beacon has two ways into one queue of reports. The page is a web form published as a Claude artifact; the app opens it with the machine's details in the link. The native sheet is a SwiftUI flow inside the app that collects diagnostics, captures the screen and files a GitHub issue through a transport. A Claude session, the pickup, turns page reports into GitHub issues and works every report by Triage/TRIAGE.md.
Parts¶
| Part | What it does | Where it lives |
|---|---|---|
BeaconCore |
Report values, completeness rules, issue rendering, secret sweep, consent, the transport protocol, the app map, the inbox link, seeding, PlatformWording. No UI, no platform APIs |
Sources/BeaconCore |
BeaconDiagnostics |
The log ring, environment probe, folder scan, context collector, report archive | Sources/BeaconDiagnostics |
BeaconIntelligence |
The on-device completeness check on Foundation Models | Sources/BeaconIntelligence |
BeaconCapture |
Screenshots, screen recording, frames from video, picked photos and videos | Sources/BeaconCapture |
BeaconGitHub |
GitHubClient, device flow, keychain token store, the four transports |
Sources/BeaconGitHub |
BeaconUI |
BeaconSheet, FormSteps, FeedbackSession, BeaconWalkthrough |
Sources/BeaconUI |
Beacon |
What a host imports: Beacon.configure, the view modifiers, BeaconReportButton, BeaconInboxButton |
Sources/Beacon |
beacon-index |
CLI that writes the app map, BeaconIndex.json |
Sources/beacon-index |
| The page | Tester form and the board, in one file | Inbox/index.html |
| The policy and pickup prompt | Rule documents an agent follows | Triage/TRIAGE.md, Triage/PICKUP.md |
| Skills | beacon-setup and beacon-triage |
.claude/skills/ |
| Plugin | plugin.json, whose skills points at ./.claude/skills, and marketplace.json, pinned to the release tag |
.claude-plugin/ |
| Workflows | ci.yml for this repository; beacon-triage.yml and beacon-reproduce.yml to copy into an app repository |
.github/workflows/ |
| Issue forms | Bug and feature forms for people filing by hand | .github/ISSUE_TEMPLATE/ |
| Scripts | beacon-labels.sh creates the labels; beacon-adopt-github.sh copies the GitHub pieces into an app repository and runs it |
Scripts/ |
| Example app | The smallest host, for iPhone, iPad and Mac, built from project.yml with xcodegen. Reports go to LocalBundleTransport |
Examples/BeaconExample |
| Tests | Five targets, one per library target except BeaconUI and Beacon |
Tests/ |
Package.swift uses Swift tools 6.2 and targets macOS 26 and iOS 26. Products: Beacon and BeaconCore (libraries) and beacon-index (executable).
Each target imports only the targets above it in the table. BeaconDiagnostics, BeaconIntelligence, BeaconCapture and BeaconGitHub depend only on BeaconCore. BeaconUI depends on all five, and Beacon depends on BeaconUI. Beacon re-exports BeaconCore, BeaconDiagnostics and BeaconGitHub, so import Beacon is the only import a configuration call needs.
Contents¶
- The report
- Completeness rules
- Issue rendering and labels
- Secret sweep
- The page
- The pickup
- The native sheet
- Transports
- GitHub calls
- Diagnostics
- Capture
- On-device check
- Consent
- The indexer
- Seeding and reproduction
- Operating the page
- Tests
- Conventions
- Decisions
The report¶
FeedbackReport in BeaconCore/Report.swift is the one shape every stage works on.
FeedbackReport
id: UUID reference = "BN-" + first 6 characters of id, uppercased
startedAt: Date when the session started, not when the reporter sent
reporter: Reporter accountID (required), displayName?, contact?
title: String blank means the renderer derives one
body: ReportBody .bug(BugBody) | .feature(FeatureBody) | .feedback(FeedbackBody)
impact: Impact blocked | slowed | irritating | noticed (rank 0..3, 0 is worst)
areaID: String? an area id from BeaconIndex, or "not-sure" / "something-new"
attachments: [Attachment]
context: ReportContext app, environment, settings, fileTrees, log, hostNotes
consentVersion: String the ConsentNotice version the reporter accepted
review: CompletenessReview?
BugBody whatHappened, expected, steps [String], reproducibility (every-time | sometimes | once | unknown)
FeatureBody whatIWant, why, areaID?, isNewArea
FeedbackBody message, areaID?
FeedbackKind bug | feature-request | feedback
Severity critical | high | medium | low (set by triage only)
impact defaults to slowed. Reporter.accountID is required because an anonymous report cannot be followed up.
Completeness rules¶
CompletenessRules in Completeness.swift is the deterministic gate. A blocking issue stops a send. A non-blocking issue is shown and the reporter can still send.
| Kind | Field | Rule | Blocks |
|---|---|---|---|
| Bug | whatHappened |
Not empty, not a placeholder, at least 12 characters | Yes |
| Bug | expected |
Not empty, not a placeholder, at least 12 characters | Yes |
| Bug | steps |
At least one non-blank step; a single step is at least 12 characters; not every step a placeholder | Yes |
| Bug | reproducibility |
unknown gets a nudge to try again |
No |
| Feature | whatIWant |
Not empty, not a placeholder, at least 12 characters | Yes |
| Feature | why |
Empty or a placeholder gets a prompt | No |
| Feature | area | An area is picked, or isNewArea is set |
Yes |
| Feedback | message |
Not empty, not a placeholder, at least 12 characters | Yes |
minimumMeaningfulCharacters is 12. A placeholder is an answer such as "n/a", "idk", "asdf" or "it broke", matched whole and case-insensitively after trimming punctuation. The full list is CompletenessRules.placeholders.
The page carries a copy of these rules and their messages in JavaScript. Change Completeness.swift first, then Inbox/index.html, and keep the messages identical. The page also requires a reporter name of at least 3 characters and a chosen app. It has no area rule for feature requests.
Issue rendering and labels¶
IssueRenderer.render in IssueRendering.swift turns a report into IssueDraft {title, body, labels}.
Title. The reporter's title, or the first sentence of whatHappened, whatIWant or message, cut on a word boundary at 72 characters. A real area adds a [Area name] prefix.
Body, in order:
- A quoted line naming the reporter and saying they agreed to be contacted.
- The kind's sections. Bug:
## What they expected,## What actually happened,## Steps to see it,## Does it happen again?. Feature:## What they want to be able to do, a## Whysection whenwhyis set,## Where it belongs(with the area's source paths). Feedback:## What they said. ## How much this affects them.## What they attached, when there are attachments.- Collapsed
<details>blocks: app, machine and settings; one per folder listing; the log tail. ## Checked before sending, when the on-device check ran and asked questions.- A hidden
<!-- beacon-metadata {...} -->JSON block that triage parses.
The reporter's words are quoted verbatim, every line prefixed with >.
Metadata keys: beacon_schema ("1"), report_id, reference, kind, impact, area, account, app_version, app_build, consent_version, started_at, plus commit, reproducibility, step_count and review_source when they apply. Every value is a string.
Labels set by the app: beacon, type:<kind>, impact:<impact>, and area:<id> unless the area is not-sure. The app never sets severity:*.
IssueRenderer.Labels holds the whole label vocabulary, including the labels triage sets: needs-info, cannot-reproduce, expectation-mismatch, working-as-intended, auto-fixed, needs-human, triaged and severity:*. Scripts/beacon-labels.sh <owner/repo> creates all of them except area:* on a repository.
Secret sweep¶
Redactor in Redaction.swift replaces each match with [removed by Beacon] and returns a RedactionFinding naming what it looked like and where.
- Patterns: 13 credential shapes, including Anthropic, OpenAI-style, GitHub, AWS, Google, Slack and Stripe keys, private key blocks, bearer tokens, JSON web tokens,
password=-style settings and URLs with a password. - Host secrets: strings from
BeaconConfiguration.hostSecrets. Values shorter than 8 characters are ignored. - Text attachments: files whose extension is in
AcceptedFormats.textExtensionsare swept. Images, PDFs and video pass through unchanged. - Paths:
Redactor.redactHometurns the home directory into~.
The sheet sweeps attachments first, then the rendered issue body. The done screen lists the findings.
The page¶
Inbox/index.html is one file published as a Claude artifact with the db and artifact capabilities. What a tester sees is in the testers' page, and the board is described in The board.
Views¶
- Tester view (default): the form, then a receipt with the reference. It never shows other reports.
- Board (
?view=board): every report, newest first, with filters for app, status and kind, and a text search. Each report expands to the full record. Images load the first time a report is opened. The board sets the document title to "Beacon reports".
Query keys¶
The page reads these keys from the link, and BeaconInbox.Key in InboxLink.swift is the Swift side of the same list:
app bundle version build commit repo os osVersion device arch locale tz appearance textSize reporter area
They fill the page's ctx object, except app, which picks the app, and reporter and area, which fill form fields. A missing locale falls back to the browser language and a missing tz to the browser time zone. view=board is read only by the page. BeaconInbox.url leaves out empty values and keeps any query the page URL already had. InboxLinkTests.everyKeyIsOneThePageReads compares BeaconInbox.Key against a list copied from the page, so add a new key to the page, the enum and that list together.
app matches an app by id or by name, case-insensitively. When the link also carries version, the app picker is locked.
Storage¶
The artifact's database holds JSON documents at slash paths. A document is at most 256 KiB. Writes are last-writer-wins.
apps/<id> seeded with write_db; the page never writes it
name, platform, repository, folder, tracker ("github" | "board")
reports/<BN-reference> written by the page on send
reference, kind, filedAt (ISO 8601), status ("new"), title, reporter (string),
impact, area, attachmentCount, userAgent, source ("beacon-inbox"),
app {id, name, repository, platform},
body bug: {kind, whatHappened, expected, steps[], reproducibility}
feature-request: {kind, whatIWant, why}
feedback: {kind, message}
context {app, bundle, version, build, commit, repository, os, osVersion, device,
architecture, locale, timeZone, appearance, textSize} strings, "" when unknown
written by the pickup:
status, issueNumber, issueURL, issueFiledAt, finding {intent, citations[], verdict},
triageNote, fixCommit, triagedAt, duplicateOf
reports/<BN-reference>/attachments/<n> one document per image, n from 1
filename, contentType ("image/jpeg"), dataURL, byteCount, width, height,
originalByteCount, reference, order
The page reads name, repository and platform from apps. Only the pickup reads folder and tracker. The page's reference is BN- plus 3 random bytes in uppercase hex, so it has the same form as the sheet's reference.
The page knows these status values (its STATUS map): new, filed, triaging, auto-fixed, needs-human, needs-info, cannot-reproduce, working-as-intended, expectation-mismatch, triaged. Any other value renders as a grey pill showing the raw value. The board wording for each is in How it works.
Images. The browser shrinks each image with createImageBitmap and a canvas to at most 1600 px on the long edge (IMAGE_MAX_EDGE). It encodes JPEG at quality 0.85, 0.75, 0.65, 0.55 then 0.45 until the result is at most 180 KiB (IMAGE_TARGET_BYTES), and refuses the image if it is still larger. A report holds at most six images (IMAGE_MAX_COUNT). Images come from the file picker (accept="image/*") or a paste. Each image is its own document so the board can list reports without loading pictures.
The doorbell¶
After the report and its images are written, the page calls:
artifact.publish({ "data/doorbell.json": JSON.stringify({ reference, filedAt }) })
This is the files form of publish. It mints a new artifact version and leaves the sending view running. A Claude Code session watching the artifact is told about new versions; database writes alone are not new versions.
If the publish is refused (not_writer, capability_disabled or another code), the report is already in the database and a scheduled pickup finds it. The page logs the code to the console and shows the tester the normal receipt.
Runtime facts¶
claude.use("db")andclaude.use("artifact")resolve after the script's first run, or tonull. The page renders without them. Whendbisnull, it shows the "can't reach the inbox" line.- An artifact that declares
dbis organization-internal. Every reader and writer is a signed-in member of the owner's Claude organization, and it cannot be shared publicly. - By default only viewers with "Can interact" or higher write shared documents. A view-only viewer cannot send a report.
- Publishing a version needs "Can edit". A tester with "Can interact" can send, but the doorbell is refused with
not_writer. - The board subscribes with
onSnapshottoreports, ordered byfiledAtdescending, limit 500. Both views subscribe toapps, ordered byname.
The pickup¶
The pickup is a Claude session that follows Triage/PICKUP.md. The prompt has four values to fill in: BEACON_PAGE, BEACON_REPO, CODE_ROOT and NOTIFY.
| Runner | When it runs | Local files and tools |
|---|---|---|
| An interactive Claude Code session watching the artifact | When the doorbell publishes a new version | Yes |
| A Desktop scheduled task | On its schedule, minimum interval 1 minute, while the machine is on | Yes |
| A cloud routine | On its schedule, minimum interval 1 hour | No: a fresh clone of the repository |
The database calls go through read_db and write_db (in Claude Code, the ArtifactData tool):
read_db query collection=reports where=[["status","==","new"]]
read_db list collection=reports/<ref>/attachments out_dir=<scratch> (decode each dataURL)
read_db query collection=reports where=[["app.id","==",<id>]] (earlier findings for duplicates)
write_db update collection=reports doc_id=<ref> data={status, issueNumber, issueURL, issueFiledAt, ...}
write_db set collection=apps doc_id=<id> data={name, platform, repository, folder, tracker}
The GitHub calls, for apps with tracker: "github":
gh issue list --repo <owner/name> --label beacon --state all --limit 100 --json number,title,labels,body
gh issue create --repo <owner/name> --title ... --label beacon --label type:<kind> --label impact:<impact> --body ...
gh issue comment <n> --repo <owner/name> --body ...
gh issue list --repo <owner/name> --label beacon --state open --search "-label:triaged -label:needs-info"
Order of work, as the prompt states it:
- Query reports with status
new. For each, oldest first, read its images and its app. - Compare it with earlier reports for the same app that carry a finding. On a match, set
duplicateOf, copy the status, finding and note, and comment on the existing issue. - Otherwise search the repository's
beaconissues. On a match, comment and reopen if closed. With no match, create the issue. Commit images to thebeacon-attachmentsbranch under.beacon/attachments/<reference>/and link them. - Write
status: "filed",issueNumber,issueURLandissueFiledAtback to the report. - Work the open
beaconissues by the triage skill,impact:blockedfirst. Write every outcome back to the page report: status, finding, triageNote, fixCommit, triagedAt. - For apps with
tracker: "board", skip GitHub and work each new report from its record. - Send one message to
NOTIFYonly if something was filed or worked.
The native sheet¶
FeedbackSession in BeaconUI/FeedbackSession.swift owns one report from start to finish. Its steps: consent, pickKind, form, review, questions, sending, done, and noReporter when currentReporter returns nil.
On send, the session:
- Runs the on-device check once, if it is available. If it returns questions, it shows them. Answers are appended to the matching field, never replacing it.
- Sweeps every attachment for secrets.
- Renders the issue, then sweeps its body.
- Saves the report with
ReportArchive.savetoreportArchiveDirectory. - Calls the transport. On an error, the receipt says the report is saved and where, with
isFiled: false.
admit(_:) applies the size limits to every attachment, whatever produced it: 25 MiB per file (AcceptedFormats.maximumFileBytes) and 60 MiB in total (maximumTotalBytes). The session stops a recording itself at maximumRecordingSeconds (default 180). A recording failure is kept on the session as recordingProblem, because on iOS the view that pressed stop is gone by the time the answer arrives.
On iOS, while recording, the sheet shrinks to BeaconSheet.recordingDetent (112 points high). It sets presentationBackgroundInteraction(.enabled) so the app is usable behind it, and interactiveDismissDisabled so a swipe cannot lose the recording.
Transports¶
ReportTransport (in BeaconCore) has destinationDescription, shown on the review screen, and submit(ReportSubmission) async throws -> SubmissionReceipt. ReportSubmission carries the report, the rendered IssueDraft and the swept attachments. SubmissionReceipt carries summary, issueNumber?, url? and isFiled.
| Transport | Needs | Does |
|---|---|---|
GitHubIssueTransport(client:attachmentBranch:) |
A GitHub token for the repository; the device flow asks for the repo scope |
Ensures the branch (default beacon-attachments), puts each attachment at .beacon/attachments/<reference>/<filename>, turns the filenames into links, creates the issue. A 403 or 404 on the attachments alone means the account may read but not write: the issue is filed anyway, with a note that the files stayed on the device |
SignedInGitHubIssueTransport(owner:repository:account:attachmentBranch:) |
A GitHubAccount somebody has signed in to |
Reads the token from the keychain at submit time, then does what GitHubIssueTransport does. A 401 signs the account out, so the next report offers sign-in rather than failing again |
RelayTransport(endpoint:appToken:destinationName:) |
A service you run | Checks the base64 size of the attachments against 60 MiB, then POSTs JSON {title, body, labels, reference, account, attachments: [{filename, base64}]} with Authorization: Bearer <appToken> when set. Reads issue_number and html_url, or error on failure |
LocalBundleTransport(folderProvider:handoverInstruction:) |
Nothing | Returns the folder from folderProvider with isFiled: false |
FallbackTransport(primary:fallback:onFallback:) |
Two transports | Tries primary. On an error, calls onFallback, submits to fallback and prefixes the receipt summary with why |
The archive folder is <yyyy-MM-dd-HHmmss>-<reference> inside reportArchiveDirectory. It holds report.json (without attachment bytes), issue.md (title, labels and body) and attachments/ with each file under a flattened name. The default directory is Application Support/<app name>/Beacon/reports.
GitHub calls¶
GitHubClient in GitHubClient.swift calls https://api.github.com with Authorization: Bearer <token>, Accept: application/vnd.github+json and X-GitHub-Api-Version: 2022-11-28. It uses only published endpoints.
| Method | Path | Used by |
|---|---|---|
POST |
repos/{owner}/{repo}/issues with {title, body, labels} |
createIssue, returns number and html_url |
GET |
user |
currentLogin |
PUT |
repos/{owner}/{repo}/contents/{path} with {message, content (base64), branch} |
putFile, returns content.html_url |
GET |
repos/{owner}/{repo}/git/ref/heads/{branch} |
ensureBranch: does the branch exist, and the default branch's head |
GET |
repos/{owner}/{repo} |
ensureBranch: the default branch name |
POST |
repos/{owner}/{repo}/git/refs with {ref, sha} |
ensureBranch: create the branch from the default branch's head |
GitHubClient.explain(status, message) turns a failed status into a sentence a reporter can act on:
| Status | Meaning given |
|---|---|
| 401 | The sign-in expired |
| 403 with "rate limit" in the message | Rate-limited, the report is saved, try again in a few minutes |
| 403 | The account cannot file into that repository |
| 404 | The repository cannot be found or seen |
| 410 | Issues are switched off on that repository |
| 422 | GitHub refused the report as written, with its message |
| Other | GitHub's own message |
Device flow¶
GitHubDeviceFlow(clientID:scopes:) in DeviceFlow.swift signs a reporter in without a client secret. scopes defaults to ["repo"].
begin()posts tohttps://github.com/login/device/codeand returnsChallenge {userCode, verificationURL, deviceCode, expiresAt, pollInterval}. Missing values default to 900 seconds to expire and a 5-second interval.awaitToken(_:)pollshttps://github.com/login/oauth/access_token.authorization_pendingkeeps polling,slow_downadds 5 seconds to the interval,access_deniedthrowsdeclined, andexpired_tokenthrowsexpired.begin()explains a refusal throughexplainRefusal(_:). GitHub answers an unregistered client id with{"error": "Not Found"}and no description, so that case names the Client ID rather than blaming the network.GitHubTokenStore.save,readanddeletekeep the token in the keychain as a generic password, servicebeacon.github, accessible after first unlock.write(_:account:service:)returns theOSStatusinstead of a Bool, andexplain(_:)turns it into a sentence:errSecMissingEntitlementmeans the build is unsigned and has no keychain.GitHubAccount(clientID:service:)is what a host uses.loginandtokenread the keychain,reporteris whatcurrentReporterreturns,connect(token:)asks GitHub whose token it is and keeps both items, andsignOut()removes them. Two items live under the service: the token under the login, and the login under.signed-in-login, a name no GitHub login can take.BeaconSheetshowsGitHubSignInViewin place of "you'll need to be signed in" when the host passed agitHubAccount. Signing in rebuilds the session, so the reporter lands on consent or the kind picker with nothing retyped.
The adopter registers the OAuth app under their own account. The steps are in GitHub setup.
Diagnostics¶
BeaconLog.shared: an in-memory ring, default capacity 2000 lines (minimum 50), default minimum levelinfo. Each line also goes to the system log under its category.category("sync")returns a writer for one category. The lastlogTailLineCountlines (default 400) ride on every report.EnvironmentProbe.snapshot(): OS name and version, device model, architecture, locale, time zone, memory, free disk, and whetherSystemLanguageModel.defaultis available, read as state without running the model. On macOS it adds appearance and reduced motion, and on iOS also text size. The model ishw.modelon macOS, andhw.machineon iOS. In the iOS Simulator it isSIMULATOR_MODEL_IDENTIFIERplus(Simulator).FileTreeScanner.scan: callscontentsOfDirectoryandresourceValuesonly and never opens a file. It walks breadth-first, bounded bymaximumDepth(default 4) andmaximumEntries(default 800). Directories inFileTreeRoot.defaultSkipsare listed as(skipped)and not walked. It setstruncatedwhen it stops early and redacts the root path.ContextCollector(configuration:log:).collect(): assemblesReportContext, with the folder scan off the main actor.ReportArchive(directory:):save(_:)returnsSavedReport {folder, reportJSON, issueMarkdown, attachments}.saved()lists saved folders, newest first.
Capture¶
MacCapture.swift and IOSCapture.swift present the same three names, so FeedbackSession has one code path: ScreenPermission.ensure(), ScreenCapturer().screenshot(), and ScreenRecording.start(maximumSeconds:) with finish() and cancel().
| macOS | iOS | |
|---|---|---|
| Screenshot | ScreenCaptureKit, SCShareableContent.currentProcess, the largest on-screen window over 80 points in each direction |
UIGraphicsImageRenderer over the key window's root view controller's view with drawHierarchy. A presented sheet sits beside that view, so the picture shows the app and not the form |
| Recording | SCRecordingOutput to .mp4, 12 frames per second, cursor shown, capturesAudio = false |
RPScreenRecorder.shared() with microphone and camera off, stopped with stopRecording(withOutput:) to .mov |
| Permission | ensure() checks CGPreflightScreenCaptureAccess, then asks with CGRequestScreenCaptureAccess |
No preflight. ensure() returns isAvailable. Declining the system prompt becomes CaptureError.permissionDenied |
Capture.swift holds the shared parts:
CaptureError, whose sentences name the platform throughPlatformWording.RecordingFrames.extract(from:count:):AVAssetImageGeneratorpullscountPNG frames (default 6, at least 2), evenly spaced from 0.05 seconds in to 0.05 seconds before the end, long edge at most 1600 px. A recording always attaches the video, then its frames.PickedMedia.attachments(data:type:): a photo or video from the library. Photos are re-encoded from their pixels so location and camera metadata are dropped. Videos get frames like a recording. Formats outsideAcceptedFormatsare refused.
AcceptedFormats accepts text and source files, images (png, jpg, jpeg, heic, heif, gif, webp, tiff, bmp), pdf, and video (mov, mp4, m4v). The full lists are in Attachment.swift.
On-device check¶
CompletenessReviewers.standard() returns OnDeviceCompletenessReviewer where Foundation Models can be imported, and NoReviewer otherwise.
OnDeviceCompletenessReviewerruns aLanguageModelSessionwith greedy sampling. The model answers in a@GenerableVerdict:readsAsCompleteand at most three questions, each with afield,questionandreason.- Questions decide the result: any question means
readsAsCompleteisfalse. Blank questions are dropped. An unknown field name falls back towhat-happened. - The check gives up after 20 seconds (
timeout), or on any error, and the report goes as written withsource: .skipped. NoReviewerreturnsCompletenessReview.notReviewed, withsource: .unavailable, so a report says it was not reviewed.availability()readsSystemLanguageModel.default.availabilitywithout starting a session.
Consent¶
ConsentNotice.current is versioned data (currently 2026-09-07.1). naming(_:) replaces $ORG with organizationName. Acceptance is stored per version and per account through ConsentStoring. The default UserDefaultsConsentStore uses keys beacon.consent.<accountID>. Changing any wording means changing the version, and every reporter is asked again.
The indexer¶
swift run beacon-index --source <dir> --output <file> [--app-name <name>] [--overrides <file>] [--commit <sha>] [--quiet]
--output defaults to <source>/BeaconIndex.json. The flags are documented in the options reference.
How it builds the map:
- Build units. The folders under
Sources/when it exists, otherwise the top-level folders. Common build, dependency and test folders (includingTests) are ignored. - Screens. In each
.swift,.mor.mmfile: a// beacon:screen Namecomment, or a type that conforms toView,NSViewController,UIViewController,NSWindowControllerorScene.// beacon:ignoreon the line skips it. Names are split from camel case with theView,ViewController,WindowController,ScreenorScenesuffix dropped, soProjectSettingsViewreads "Project Settings". - Overrides. A JSON file with
areaskeyed by generated id (name,blurb,hidden,absorbs),extraAreas, andignore(folder names).absorbsis applied first. - Output.
BeaconIndex.jsonwithschemaVersion1, sorted by name.
BeaconIndex.decode refuses a map with a newer schemaVersion. loadFromBundle(_:) reads BeaconIndex.json from a bundle. hiddenFromReporters areas stay routable but are not offered on the picker. CI runs the indexer on this package and fails if the output file is empty.
Seeding and reproduction¶
BeaconSeed.applyIfRequested(into:) replaces the host's data directory with a seed folder at launch. It acts only when BEACON_SEED_ENABLE is 1 and BEACON_SEED_DIRECTORY names an existing folder. Every outcome returns Result {applied, explanation}. isReproductionRun() is true when BEACON_SEED_ENABLE is 1.
| Workflow | Trigger | Runner | What it does |
|---|---|---|---|
beacon-reproduce.yml |
workflow_dispatch with issue (required), commit, seed (default default) |
macos-26, 30-minute limit |
Checks out the commit, runs swift build, sets both seed variables to Triage/seeds/<seed>, then fails on purpose at the run step until it is pointed at the app's UI test scheme. Uploads .xcresult bundles and screenshots |
beacon-triage.yml |
Weekdays at 08:00 UTC, or workflow_dispatch with an optional issue |
ubuntu-latest, 45-minute limit |
Runs anthropics/claude-code-action@v1 with /beacon-triage, using the CLAUDE_CODE_OAUTH_TOKEN secret |
ci.yml |
Push to main, pull requests |
macos-26, 20-minute limit |
swift build, swift test, and the indexer on this package |
beacon-triage.yml does not start beacon-reproduce.yml; each is run on its own. Runner facts and costs are in CLOUD-REPRODUCTION.md. Scripts/beacon-adopt-github.sh creates Triage/seeds/ in the app repository.
Operating the page¶
- Republish after any change to
Inbox/index.html: call the Artifact tool withfile_pathset to the file andurlset to the artifact. Omitcapabilitiessodbandartifactcarry forward, and omitfaviconso it keeps its icon. - Add an app: one
write_dbset onapps/<id>withname,platform,repository,folderandtracker. No republish is needed; both views readappslive. - Move an app off GitHub:
write_dbupdateapps/<id>withtracker: "board". - Create labels on a repository:
Scripts/beacon-labels.sh <owner/repo>, withghsigned in. - Give a tester access: share the artifact with "Can edit" so the doorbell publishes. "Can interact" can send, and a scheduled pickup finds the report.
- Inspect the store from a session:
read_dblistreports, getreports/<ref>, or listreports/<ref>/attachmentswith anout_dir.
Before republishing, check the script parses, for example with node -e "new Function(<script body>)". That check does not catch errors that happen at load, so open the page and send a report.
Tests¶
Run the tests on the Mac:
swift test
Run the same suites on the iOS Simulator:
xcodebuild test -scheme Beacon-Package -destination 'platform=iOS Simulator,name=iPhone Air'
The source declares 94 tests in 22 suites. On the Mac, swift test runs 93: one test in DiagnosticsTests builds only for iOS. LiveTransportTests is skipped unless BEACON_LIVE_GITHUB_REPO and BEACON_LIVE_GITHUB_TOKEN are both set. It files a real issue, so close it afterwards.
| Target | Covers |
|---|---|
BeaconCoreTests |
Completeness rules, issue rendering, the secret sweep, accepted formats, consent, the app map, seeding, the inbox link, platform wording |
BeaconDiagnosticsTests |
The log ring, the folder scan (a known string written into a scanned file is asserted absent), the archive, the environment probe |
BeaconGitHubTests |
The four transports, GitHub's status codes as sentences, the relay size limit, and the live test |
BeaconIntelligenceTests |
Honest unavailability, the prompt's fields, and how a verdict becomes a review |
BeaconCaptureTests |
Frame offsets and extraction, picked photos and videos, metadata removal, refused formats, platform sentences |
BeaconUI, Beacon and the page have no automated tests. A change to the sheet is walked in Examples/BeaconExample on each platform it touches.
Conventions¶
- Every documentation page has
*Last updated: YYYY-MM-DD*under its title. Change the date when you change the page. - Kinds, impacts, statuses and labels are defined once, in
IssueRenderer.Labelsand the page'sSTATUSmap. A copy elsewhere needs a test that holds it equal. - Deployment facts (the page URL, a repository, a folder, a branch) come from configuration or from the system, never from a literal at the point of use.
- Anything a reporter reads that names the device goes through
PlatformWording. - Platform differences live behind one shared name. Use
#ifin a view body only when a platform lacks the control, as inexclusiveChoiceStyle()andFlowingButtons. - Build a change to
BeaconUI,BeaconCaptureorBeaconDiagnosticsfor the iOS Simulator as well as the Mac, because CI builds only on macOS:
xcodebuild build -scheme Beacon -destination 'generic/platform=iOS Simulator'
- On a release, bump the version in
plugin.jsonandmarketplace.jsonand the tag together.
Decisions¶
A bug cannot be filed without what was expected, what happened and the steps. Only the reporter knows them, and they cannot be recovered later. The rule is deterministic, so it holds on every device with or without a model, and placeholder answers are refused too.
The on-device check asks and never blocks. It is a model's opinion about prose, so it asks at most three questions, once, never rewrites the reporter's words, and the reporter can always send.
The check runs on the device or not at all. A report is someone's unredacted description of their work, so it never goes to Private Cloud Compute or a server.
Beacon reads about files, never inside them. The folder scan lists names and sizes because the layout answers most questions. Files the reporter attaches are the only files read.
Reports are not anonymous, and the reporter is told first. A report that cannot be followed up is rarely fixed. The consent wording is versioned, and the version accepted is recorded on each report.
The secret sweep runs last and shows what it found. Running last means nothing is added after it. Silent scrubbing would hide a leak in the host app's logging.
Only formats the triaging agent can read are accepted. An attachment nobody can read adds nothing. A recording always carries still frames, because an agent cannot watch video.
Only the host app is captured. On macOS the capture asks only for this process's windows, and on iOS the screenshot is drawn from the app's own views. The reporter does not have to trust that nothing else is recorded.
The app never sets severity. The reporter says what it costs them. Severity is what it costs everyone, and that is triage's call.
A report is saved to disk before it is sent. The network is the one part nobody controls, so a failed send never loses a report.
Attachments go to their own branch through the contents API. The issue API cannot take a file, and the browser uploader is not a published endpoint. A separate branch means a report never touches a branch anyone builds from.
The reproduction workflow fails until it is wired. A half-configured workflow that passes would report a green run that proved nothing.