Skip to content

connect: hand off an operator's ping - #914

Draft
robzolkos wants to merge 6 commits into
pings-commandsfrom
connect-pings
Draft

robzolkos wants to merge 6 commits into
pings-commandsfrom
connect-pings

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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 into admission.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 participant there, so subagent.md's A participant asks, an operator authorizes applies, and owner is 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_performer unless 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. Decide discards the event unless the Ping read confirms the bucket is a Ping. matchPing takes the author's role from the operator set or the Ping's entry, and gives pinged only when both performer and author are operators.
  • No agent. Policy.Validate refuses the agent itself and any --allow-agent agent 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.Parse refuses unknown keys, so an older CLI refuses a connect.json that has a pings entry. connect, show and setup all 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 without setup.Load.
  • Trust is read when the connector starts, so restart it after a change, as for every other trust change.
  • connect show lists 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.created in 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 project it also prevents a membership read on the Circle, which bc3 answers 404 and which used to hold the line read_failed.
  • matchPing holds the Ping rules in one place. The author must be an operator and no Agent. A mention gives mentioned. A direct Ping gives pinged. Anything else is not_addressed.
  • TriggerPinged is 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. Served stays false and Class stays 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.md covers the pinged trigger and the ping key. For repo choice, a Ping has no AGENTS.md or last.json mapping, so the agent uses the repo the request names or asks. The owner rule now accepts mentioned or pinged. 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.md says to read a Ping with basecamp pings messages, to skip AGENTS.md, and to reply with basecamp pings post <bucket_id> -. It also describes the pinged trigger.
  • connect setup --help has a Pings paragraph, and connect --help and --project mention 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 held read_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.go and TestConnectSetupLetsAPersonIntoOnePing. 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. TestThePingReadTellsAPingFromAProject covers 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 project from a throwaway state directory.
    • The operator's direct Ping line was admitted as pinged and its request line had ping:true, owner:true, and reply_to set to the Ping's Campfire.
    • A project member's direct Ping line was discarded as untrusted_performer. Two earlier lines from the same member, held read_failed, were retried and also ended untrusted_performer.
    • An operator's line in a group Ping without a mention was discarded as not_addressed. With a mention it was admitted as mentioned, with ping:true.
    • As the agent, a boost on the line's URL, chat line <url>, pings messages and pings post <ping> - all worked.
    • The Pings that already existed at start caused no reconnect. A group Ping created after start caused one.
  • bin/ci passes locally, including skill drift, the request contract and the compaction budget. The one exception is the SDK provenance check, which it skips while Add pings commands: list, show, start, messages, post, upload #913's replace points at the local SDK. Local golangci-lint 2.15 flagged cap shadowing a predeclared name in policy.go. CI pins 2.11.1, which does not flag it, and I renamed it to limit.

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. Previously every line in a Ping was blocked because Pings are not served projects.

  • Only operators can give the agent work by Ping: direct Ping lines are requests, and group Ping mentions are requests, in every trust mode.
  • Adds the pinged trigger, the "ping": true request flag, and the ping:<id> conversation key; no setup or re-run needed.
  • The live feed subscription now lists the agent's Pings, stopping a reconnect loop that repeated every 10 minutes per active Ping.
  • A run narrowed with --project hears no Ping, and no ledger schema changes.

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 (mentioned, role: participant) Only a line that @mentions the agent
Anyone else, including project trust's members Discarded (untrusted_performer), no read Discarded (untrusted_performer), no read
The agent itself, another agent, a delegated line Discarded (agent_authored, delegated) Discarded

Project trust's participants are a project's members, and a Ping belongs to no project, so they get nothing here. That also keeps the agent caps out of Pings. Each line's author must be an operator as well as its performer; the owner's direct-ping line counts as their own word, like their mention.

Connecting a person to one Ping

  • connect setup --allow-in-ping <ping-id>:<person-id> and --disallow-in-ping write connect.json's pings key; a named person is a participant in that Ping alone, by mention only, and everywhere else is whatever the rest of the policy makes them.
  • A file with no entries keeps no pings key, so an older CLI still opens it; an older CLI refuses one that has it (unknown field).
  • Setup reads each Ping as the agent and refuses a Ping the agent cannot read, a person not among its people, the agent, an Agent, and a client. A recorded person who has since left, or whose Ping can no longer be read, is a warning; connect show lists each Ping's participants.

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.
  • matchPing holds the rules in one place. TriggerPinged is a new trigger, not a matrix row. "Direct" is computed from Ping.People as 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 now takes records whose conversation is ping:<id> (admission writes it only after the Ping read says the bucket is one). Older CLIs can still open the ledger.
  • The request line gains ping: true|false, added to the request contract.

Skill and docs

  • SKILL.md and subagent.md cover the pinged trigger, the ping key, and the per-Ping participant row. A Ping has no project: no AGENTS.md or repo mapping, so the agent uses the repo the request names or asks, and replies with basecamp pings post <bucket_id> -.
  • Setup, the guided summary and the no-project warning now say operators' pings reach the agent with no setup.

Verification

  • Admission tests in pings_test.go were written before the code and failed against it; each of the 7 rules removed in turn failed its test. The handoff, membership, and SDK adapter tests fail without their change.
  • Per-Ping participants are covered in ping_participants_test.go and setup's pings_test.go; 18 mutations were applied one at a time, and each made a test fail.
  • End to end on bc3 dev: the operator's direct Ping line admitted pinged, a project member's discarded, a group Ping without a mention discarded and with one admitted, and boosts, chat line, pings messages and pings post all worked. Existing Pings caused no reconnect; a group Ping created after start caused one.
  • bin/ci passes locally, including skill drift and the compaction budget, except the SDK provenance check skipped while Add pings commands: list, show, start, messages, post, upload #913's replace points at the local SDK.

Depends on #913 (the pings commands) and the unreleased SDK in basecamp/basecamp-sdk#1015, through #913's replace commit. Not run: the paid connect skill evals (make -C skill-evals eval-connect).

Written for commit 14c799f. Summary will update on new commits.

View guided diff Turn on auto-fix

@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Oct 19, 2026
robzolkos and others added 3 commits October 19, 2026 17:49
A Ping is a Circle bucket, never in connect.json's served projects, so every
line in one was blocked as an unserved project's, or held read_failed in
project trust when the membership read 404ed on the Circle. Admission now
reads a chat line's unserved bucket as a Ping before anyone's trust, and
decides it by the Ping's rules:

- Only an operator (the operator, or someone the allowlist names) gives the
  agent work by Ping. Project trust's members are a project's, so anyone else
  is discarded untrusted_performer without a membership or line read.
- In a direct Ping, the agent and the writer alone, every such line is a
  request, trigger pinged. In a group Ping only a mention of the agent is.
- The agent's own line, another agent's, a delegated one and an allowed
  agent's mention are refused as before; the last is discarded at the gate
  in any bucket connect.json does not serve.

A Ping's conversation is the Ping (ping:<id>), answered in its Campfire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Ping is in no project connect.json serves, so the handoff, which hands
off only records in served projects, would have kept an admitted ping
waiting forever, and handed off nothing at all for an agent serving no
project. It now also hands off a record whose conversation is a Ping
(ping:<id>), which admission writes only after the Ping read said the
bucket is one; a run narrowed with --project hears no Ping. No ledger
schema change.

The request line gains ping: true for a Ping's line, so the session knows
there is no project, AGENTS.md or project repo, and replies in the Ping.
The skill and subagent say so, the owner's direct ping counts as their
word like their mention, and setup, the guided summary and the no-project
warning say operators' pings are heard with no setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live subscription holds every bucket the agent can see, Pings
included, but the membership listing named only projects. Every line in a
Ping therefore read as from a bucket the subscription did not hold and
reconnected the feed, again once per membership interval per active Ping.
The listing now names the agent's Pings too; Pings turned off (403) list
none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trust was agent-wide: an operator was heard everywhere, and a Ping heard
nobody else. connect.json may now name, per Ping, people who reach the
agent there and nowhere else:

  "pings": {"<ping-id>": {"participants": [<person-id>, ...]}}

Such a person's line in that Ping is a participant's request, by mention
only, in every trust mode: a direct Ping's every-line rule stays an
operator's. Anywhere else, other Pings and their direct Ping with the agent
included, they are whoever the rest of the policy makes them.

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. The entry
counts only once the Ping read says the bucket is a Ping. Validate refuses
the agent itself and any allowed agent as a participant.
  basecamp connect setup -P <agent> --allow-in-ping <ping-id>:<person-id>

writes the person into that Ping's entry in connect.json; it is repeatable,
--disallow-in-ping takes one out, and the entries are kept across runs like
the allowed agents. A Ping nobody is let into is no entry, and with none
the "pings" key is left out, so a file that uses none still opens in a CLI
that predates it.

Setup reads each Ping as the agent, the way admission does, and refuses a
Ping the agent cannot read, a person not among its people, the agent, 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. connect show lists each Ping's participants,
and the guided setup treats both flags as policy flags.
The connect skill gains the "only in our group Ping" row, a paragraph on
letting someone into one Ping (how to find the Ping's id, including the
sidebar URL form, and the person's id, what setup refuses, and the
restart), and says a participant's role covers them. Its per-request
section stays inside the compaction budget, at 13,951 of 14,000
characters. The subagent learns that in a Ping the operator's go is a
mention only, since a boost there never reaches the agent, and that an ask
the operator cannot see is said and left.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant