Connections
What the agent can reach, what it cannot, and the card it shows when it needs something connected — with the reason, and a way to answer.
A connector without a credential used to fail mid-run and return a string saying so. A missing connection is not an error for the agent to work around — it is a decision for you.
from shipit_agent import ConnectionRegistry
registry = ConnectionRegistry(credential_store=store, tools=agent.tools)
print(registry.render())✓ Slack — connected
tools: slack
· BigQuery — not connected
tools: warehouse
→ Connect BigQuery — it needs you to sign in.
⧗ Gmail — expired
→ Reconnect Gmail — its token has run out.What it looks like
The reason is the card. Somebody deciding whether to hand over an account is answering why, not what.
Five states
| State | Means | What to tell the user |
|---|---|---|
CONNECTED | Usable now | — |
DISCONNECTED | Nothing configured | "Connect it" |
NEEDS_AUTH | Configured, credential missing | "Sign in" |
EXPIRED | Was connected, token ran out | "Reconnect it" |
ERROR | Configured and failing otherwise | The error |
EXPIRED is deliberately distinct from DISCONNECTED. Telling someone to
set up something they already configured is how trust in a status display
dies.
The agent asking
agent.run("Post the release note to #eng.")The connections tool takes action="request" with a reason, and a
request without one is refused — the reason is what a user reads in order to
decide.
The run then carries an event:
{"type": "connection_requested",
"payload": {"connection_id": "warehouse",
"title": "BigQuery — analytics.usage",
"reason": "Read the usage tables to total revenue by region.",
"auth": "oauth",
"tool": "connections"}}Which every surface draws:
- Live panel — a card with the title, the reason, and
Not now · Connect - Tree — a
Connection neededbranch - Timeline — a
connection_requiredstep
The card says sign in, paste an API key or paste a token according to
the connection's auth kind, because those are different actions for the
person answering.
The agent is told to keep working on anything that does not need it, and not to retry the connector until it is connected.
Answering
registry.resolve("warehouse", accepted=True, credential="wh-key-123", by="you")On accept with a credential, it is written to the credential store — so the
very next state check reads connected rather than asking again for
something you just gave it.
registry.resolve("warehouse", accepted=False) # denied, and closedA denial closes the request too. The agent must not keep asking.
Credentials are accepted in whichever shape you have:
registry.resolve("slack", accepted=True, credential="xoxb-…")
registry.resolve("slack", accepted=True, credential={"token": "…", "team": "acme"})
registry.resolve("slack", accepted=True, credential=CredentialRecord(...))Wiring a UI
@app.post("/connections/{connection_id}")
def answer(connection_id: str, body: AnswerBody):
registry.resolve(
connection_id,
accepted=body.accepted,
credential=body.credential,
by=current_user.email,
)The pending queue is readable at any time:
for request in registry.pending_requests():
print(request.title, "—", request.reason)What the registry knows
It is built from what the agent actually has:
- Connectors — any tool carrying a
credential_key(17 built-in SaaS connectors do). Tools sharing one key — the Google family — collapse into one connection listing all of them. - MCP servers — attached servers and their state.
registry.all() # every connection and its state
registry.connected() # the usable ones
registry.needing_action() # the ones a human must fix
registry.is_connected("slack")
registry.summary() # counts, for a headerThe MCP catalog — servers you could install — is off by default:
"what's connected?" should not answer with a shopping list. Pass
include_catalog=True if you want it.
In the agent
The registry is published into tool state automatically, so the connections
tool works with no wiring:
agent.run("Which of my connections need attention?")⚯ Checked connections 2 need actionUsing it, end to end
1 · Give the agent the connections tool
It ships as a builtin, and the registry is published into tool state for you — so nothing needs wiring.
from shipit_agent import Agent
from shipit_agent.builtins import get_builtin_tool_map
from shipit_agent.integrations import InMemoryCredentialStore
tools = get_builtin_tool_map(llm=llm, project_root=".")
agent = Agent(
llm=llm,
tools=[tools["connections"], tools["slack"], tools["google_sheets"]],
credential_store=InMemoryCredentialStore(), # or your own store
)2 · Ask what is reachable
agent.run("What can you reach right now, and what needs setting up?")⚯ Checked connections 1 connected · 2 need actionOr without a model in the loop:
from shipit_agent import ConnectionRegistry
registry = ConnectionRegistry(credential_store=store, tools=agent.tools)
print(registry.render())3 · Let it ask for what it is missing
for event in agent.stream("Post the Q2 numbers to #finance."):
if event.type == "connection_requested":
card = event.payload
print(f"CONNECT {card['title']}: {card['reason']} ({card['auth']})")CONNECT Slack: Post the Q2 revenue summary to the #finance channel. (oauth)The run keeps going — the agent is told to continue with anything that does not need it, so a missing credential costs you the one step rather than the whole task.
4 · Draw the card
from shipit_agent.narrate import render_chat_html
from IPython.display import HTML
HTML(render_chat_html(events, title="Q2 numbers"))Or build your own from the payload — connection_id, title, reason,
auth. Those four fields are the card.
5 · Answer it
@app.post("/connections/{connection_id}/accept")
def accept(connection_id: str, credential: str, user=Depends(current_user)):
registry.resolve(
connection_id, accepted=True, credential=credential, by=user.email
)
return {"state": registry.get(connection_id).state.value} # "connected"@app.post("/connections/{connection_id}/deny")
def deny(connection_id: str, user=Depends(current_user)):
registry.resolve(connection_id, accepted=False, by=user.email)6 · Carry on
agent.run("Post the Q2 numbers to #finance.") # now it just worksAn OAuth round trip
resolve() is where your OAuth callback lands:
@app.get("/oauth/slack/callback")
def slack_callback(code: str):
token = exchange_code_for_token(code) # your own exchange
registry.resolve(
"slack",
accepted=True,
credential={"token": token["access_token"], "team": token["team"]["id"]},
by="oauth",
)
return RedirectResponse("/chat")Nothing about the agent needs restarting: the next state check reads the
credential store and returns CONNECTED.
Connectable tools
Every tool carrying a credential_key becomes a connection. The built-ins:
| Connection | Tools | Auth |
|---|---|---|
| Slack | slack | OAuth |
| Gmail | gmail_search | OAuth |
| Google Calendar | google_calendar | OAuth |
| Google Drive | google_drive | OAuth |
| Google Sheets | google_sheets | OAuth |
| GitHub | github | Token |
| GitLab | gitlab | Token |
| Jira | jira | Token |
| Linear | linear | API key |
| Notion | notion | Token |
| Confluence | confluence | Token |
| Figma | figma | Token |
| Salesforce | salesforce | OAuth |
| HubSpot | hubspot | API key |
| Stripe | stripe | API key |
| Zendesk | zendesk | API key |
linkedin_search | OAuth | |
| Custom API | custom_api | Whatever you configure |
Tools that share a credential — the Google family — collapse into one connection listing all of them, because that is one thing to connect.
MCP servers appear alongside them, with their own state.
Your own connector
Any tool with a credential_key joins the registry:
class Warehouse:
name = "warehouse"
description = "Query the analytics warehouse."
prompt_instructions = ""
credential_key = "warehouse" # ← this is the whole requirement
def schema(self): ...
def run(self, context, **kwargs): ...· Warehouse — not connected
tools: warehouse
→ Connect Warehouse.How each tool shows up
Connections are one of several things a run surfaces as a card rather than as text. The full set, and where each appears:
| The run emits | Panel | Tree | Timeline |
|---|---|---|---|
tool_called / tool_completed | A work row, 2 tools · 13.0ms | Tool group | tool_call_started / _completed |
action_queued | Approval card | Approval required | approval_required |
connection_requested | Connect card | Connection needed | connection_required |
artifact_created | File card | Artifact | artifact_created |
agent_decision / agent_observation | Decision step | Decision / Observed | same names |
sub_agent_event | Attributed nested row | Delegated | sub_agent_tool_call |
lockdown_engaged | Notice | Note | notice |
Every one of those arrives on agent.stream(). See
The UI timeline for the JSON, and
The live UI panel for the rendering.
See also
- Connecting SaaS — the connectors themselves
- MCP integration
- The live UI panel — where the card is drawn