connect: hand off an operator's ping
An agent like R2-D2 now answers when its operator pings it. Today basecamp connect blocks every line in a Ping. The line was never handed off, so a direct message to your own agent went nowhere (#903).
This depends on #913 (the pings commands) and on the unreleased SDK in basecamp/basecamp-sdk#1015, through #913's DO-NOT-MERGE replace commit. basecamp/basecamp-sdk#1015 is also where Summarize learned to find a Ping line's Campfire. It needs bc3 #13943 server-side. The base is pings-commands.
Who can now make the agent run
| Who | Direct Ping with the agent | Group Ping with the agent |
|---|---|---|
| The operator | Every line is a request (trigger: pinged, owner: true) | Only a line that @mentions the agent (mentioned) |
Someone the allowlist names (--allow) | Every line is a request (role: operator, owner: false) | Only a line that @mentions the agent |
Someone let into this Ping (--allow-in-ping) | Only a line that @mentions the agent (mentioned, role: participant, owner: false) | Only a line that @mentions the agent, the same |
| The same person in any other Ping | Discarded, untrusted_performer (gate, no read) | Discarded, untrusted_performer |
A project member under --trust project | Discarded, untrusted_performer | Discarded, untrusted_performer |
| Anyone else | Discarded, untrusted_performer | Discarded, untrusted_performer |
| The agent itself | Discarded, agent_authored (gate, no read) | Discarded, agent_authored |
Another agent, allowed (--allow-agent) or not | Discarded at the gate, no read | Discarded at the gate, no read |
| An agent acting for the operator | Discarded, delegated | Discarded, delegated |
Pings are on by default and need no setup. Existing setups keep working without a re-run. There is no switch to turn pings off. A run narrowed with --project hears no Ping, because the feed filter already drops its buckets.
What this does not open. Project trust's participants are a project's members, and a Ping belongs to no project, so they get nothing here. An allowed agent's mention stays a participant's request. A Ping hears none, and the gate discards it as it already does in any unserved bucket. That also keeps the agent caps out of Pings. A line's author must be an operator as well as its performer. The owner's direct-ping line counts as their word, the same as their mention, so it can carry a connector change (owner: true). The reason is that nobody but its author writes a chat line. An assignment is the opposite case, because anyone can edit the card.
A person in one Ping only
Trust used to be agent-wide: an operator was heard everywhere, and nobody else in any Ping. The operator can now let one person reach the agent in one Ping and nowhere else:
basecamp connect setup -P <agent> --allow-in-ping <ping-id>:<person-id> # repeatable
basecamp connect setup -P <agent> --disallow-in-ping <ping-id>:<person-id>
- connect.json gains
"pings": {"<ping-id>": {"participants": [<person-id>, ...]}}, read intoadmission.Policy.Pings(map[int64]PingTrust). Absent means nobody. Setup writes no key when there is no entry, so a file that uses none is byte-for-byte what it was. - Role. Such a person is a
participantthere, sosubagent.md's A participant asks, an operator authorizes applies, andowneris false. - Mention only, even in a direct Ping. Every line of a direct Ping is an operator's privilege, so a participant needs a mention everywhere. This is a decision, not a constraint: the alternative is a one-line change in
matchPing. - Anywhere else (another Ping, their direct Ping with the agent, any project, an assignment) they are whoever the rest of the policy makes them, so
untrusted_performerunless trusted some other way. Works in every trust mode, additively. - Where the rule lives. The gate opens the entry only in a bucket connect.json does not serve, and before project trust, so a Ping is never asked for members.
Decidediscards the event unless the Ping read confirms the bucket is a Ping.matchPingtakes the author's role from the operator set or the Ping's entry, and givespingedonly when both performer and author are operators. - No agent.
Policy.Validaterefuses the agent itself and any--allow-agentagent as a participant. Setup reads the Ping as the agent, the way admission does, and refuses a Ping the agent cannot read, a person not among its people, an Agent, and a client (as project trust refuses clients). A person already recorded who has since left, or whose Ping can no longer be read, is a warning, as for the allowlist. - Older CLIs.
setup.Parserefuses unknown keys, so an older CLI refuses a connect.json that has apingsentry.connect,showandsetupall stop with "unknown field" rather than run without it. That fails closed, and it happens only once someone uses the flag. Admission's own reader ignores unknown keys, but nothing reaches it withoutsetup.Load. - Trust is read when the connector starts, so restart it after a change, as for every other trust change.
connect showlists each Ping's participants. The skill has a "talk to it only in our group Ping" row and explains how to find the Ping id (pings list,.../circles/<id>or.../my/sidebar/circles/<id>) and the person id (pings show <ping> --json). The subagent learns that the operator's go in a Ping is a mention only, because a boost in a Ping never reaches the agent, and that it should stop and say so when the operator is not in the Ping.
Data shape
Reads.Pings(PingReader.Ping(ctx, bucketID) (*basecamp.Ping, error)). It returns nil when the bucket is not a Ping the agent is in: a 404, which is every project, or a 403, which means Pings are turned off. The SDK adapter caches the "not a Ping" answer for 10 minutes. A Ping itself is read fresh each time, because its people decide whether it is direct.- Admission order. For a
chat.line.createdin a bucket connect.json does not serve, the bucket is read as a Ping before any trust read. The place decides whose word counts there. Under--trust projectit also prevents a membership read on the Circle, which bc3 answers 404 and which used to hold the lineread_failed. matchPingholds the Ping rules in one place. The author must be an operator and no Agent. A mention givesmentioned. A direct Ping givespinged. Anything else isnot_addressed.TriggerPingedis a new trigger. It is not a matrix row, because only a Ping can admit under it.- Conversation key
ping:<id>(admission.PingConversationKey). The reply goes to the Ping's Campfire.Servedstays false andClassstays empty, since connect.json serves no Ping. The handoff hands off(served AND bucket in served projects) OR conversation_key = 'ping:' || bucket_id. There is no ledger schema change, so an older CLI can still open the ledger. - Request line gets
"ping": true|false, added to the request contract and the skill's example.
Direct versus group. The wire has no group flag, so "direct" is computed from Ping.People as the agent sees it, which lists the others in the Ping. The rule is exactly one other person, and that person wrote the line. One edge case is a group that has shrunk to the agent and one person: it reads as direct. I think that is fine, because nobody else reads the line any more. A direct Ping never gains a third person, since bc3 allows can_change_access? only on groups. A stale read can therefore only make a Ping look like a group, which is the safe direction.
Why not bc3's pinged addressing reason. It lives on the inbox lane, and the connector reads the account feed, where intake refuses reasons. It also addresses every participant for every line, group Pings included, so it says nothing about direct versus group or about mentions. The Ping read and the mention check would still be needed.
The live lane. The live subscription holds every bucket the agent can see, Pings included. The membership listing named only projects. Every line in a Ping therefore looked like it came from an unheld bucket and reconnected the feed, repeating every 10 minutes for each active Ping. The listing now includes the agent's Pings. If Pings are turned off (403), it lists none.
Skill and docs
SKILL.mdcovers thepingedtrigger and thepingkey. For repo choice, a Ping has no AGENTS.md orlast.jsonmapping, so the agent uses the repo the request names or asks. The owner rule now acceptsmentionedorpinged. The setup section says operators can ping the agent with no setup. The per-request section sat at 13,983 of its 14,000-character compaction budget, so some prose there was tightened to make room. It now ends at 13,974.subagent.mdsays to read a Ping withbasecamp pings messages, to skip AGENTS.md, and to reply withbasecamp pings post <bucket_id> -. It also describes thepingedtrigger.connect setup --helphas a Pings paragraph, andconnect --helpand--projectmention pings. The no-project warning and the guided summary now say operators' pings still reach the agent.
Verification
- Admission tests are in
internal/connector/admission/pings_test.go. They were written before the code and failed against it: direct pings were discarded, and a member's ping in project trust was heldread_failed. Each rule was then removed in turn from the code, and its test failed every time (7 of 7): the performer check, the author check, the "exactly one other person" rule, the "a direct line needs no mention" rule, the served-project exemption, Agent-authored words, and the owner's direct ping. - Per-Ping participants are tested in
internal/connector/admission/ping_participants_test.go,internal/connector/setup/pings_test.goandTestConnectSetupLetsAPersonIntoOnePing. The admission tests failed before the gate and verdict change. 18 mutations were applied one at a time, and each made a test fail: 9 in admission (entry for any Ping, served buckets, entry checked after project trust, no Ping-read confirmation, in-ping refused in a Ping, participant needing no mention in a direct Ping, any author, and the two Validate refusals) and 9 in setup and the command (not-in-Ping, client, Agent, unreadable Ping, recorded warning, disallow, Apply aliasing, verification unwired, show omitting pings). Each setup refusal was also checked to fail for its own reason, not an incidental one. - The handoff test (
TestAPingsRequestIsHandedOffWithNoServedProject) fails when Pings are left out of the handoff query. The membership test fails without the Pings listing.TestThePingReadTellsAPingFromAProjectcovers the SDK adapter's 404, 403 and 500 answers and its cache. - End to end on bc3 dev: a real agent credential was obtained through
auth agent connect, with the device approval done server-side. The connector ran in--trust projectfrom a throwaway state directory.- The operator's direct Ping line was admitted as
pingedand its request line hadping:true,owner:true, andreply_toset to the Ping's Campfire. - A project member's direct Ping line was discarded as
untrusted_performer. Two earlier lines from the same member, heldread_failed, were retried and also endeduntrusted_performer. - An operator's line in a group Ping without a mention was discarded as
not_addressed. With a mention it was admitted asmentioned, withping:true. - As the agent, a boost on the line's URL,
chat line <url>,pings messagesandpings post <ping> -all worked. - The Pings that already existed at start caused no reconnect. A group Ping created after start caused one.
- The operator's direct Ping line was admitted as
bin/cipasses locally, including skill drift, the request contract and the compaction budget. The one exception is the SDK provenance check, which it skips while #913's replace points at the local SDK. Local golangci-lint 2.15 flaggedcapshadowing a predeclared name inpolicy.go. CI pins 2.11.1, which does not flag it, and I renamed it tolimit.
Not run: the paid connect skill evals (make -C skill-evals eval-connect). The existing cases cover setup and repo mapping, which this keeps.
Summary by cubic
Makes the connector hand off an operator's Ping, so the agent answers when its operator pings it. Until now every line in a Ping was blocked, because Pings are not served projects.
Pings reach the agent with no setup: the pinged trigger, the "ping": true request flag, and the ping:<id> conversation key carry the line, and the ledger schema is unchanged. The live feed subscription now lists the agent's Pings, stopping a reconnect loop that repeated every 10 minutes per active Ping. A Ping has no project, so there is no AGENTS.md or repo mapping: the agent uses the repo the request names or asks, and replies with basecamp pings post <bucket_id> -.
Who can ping
| Who | Direct Ping with the agent | Group Ping with the agent |
|---|---|---|
| The operator | Every line is a request (pinged, owner: true) | Only a line that @mentions the agent (mentioned) |
Someone the allowlist names (--allow) | Every line is a request (role: operator, owner: false) | Only a line that @mentions the agent |
Someone let into this Ping (--allow-in-ping) | Only a line that @mentions the agent | Only a line that @mentions the agent |
| Anyone else, including project trust's members | Discarded (untrusted_performer), no read | Discarded |
| The agent itself, another agent, a delegated line | Discarded (agent_authored, delegated) | Discarded |
Project trust's members are a project's members, and a Ping belongs to no project, so they get nothing here. Each line's author must be an operator as well as its performer; the owner's direct-ping line counts as their word, like their mention. A run narrowed with --project hears no Ping.
Data shape
For a chat.line.created in a bucket connect.json does not serve, the bucket is read as a Ping before any trust read. The SDK adapter caches the "not a Ping" answer (404, or 403 when Pings are off) for 10 minutes; a Ping's people are read fresh, since they decide whether it is direct. Direct means exactly one other person, who wrote the line; a stale read can only make a Ping look like a group, the safe direction. The handoff takes records whose conversation is ping:<id>.
connect setup --allow-in-ping <ping-id>:<person-id> and --disallow-in-ping write connect.json's pings key, kept only when non-empty so an older CLI still opens a file that uses none (and refuses one that has it, as an unknown field). Setup reads each Ping as the agent and refuses a Ping it cannot read, a person not among its people, the agent, an Agent, and a client; a recorded person who has since left is a warning, as for the allowlist. Admission tests were written before the code and fail without each change, and per-Ping participants are covered with one-at-a-time mutations.