DEV Community

Cover image for I stopped asking clients for their passwords. Here's what I built on Switch instead. published: true
Dikshant Jangra
Dikshant Jangra

Posted on

I stopped asking clients for their passwords. Here's what I built on Switch instead. published: true

Every freelancer and agency has done this. A client hires you to fix their website or run their social media. Your first message is "can you share the login?" and a few minutes later a GoDaddy password is sitting in a WhatsApp chat, a Cloudflare login is in someone's email, and an Instagram password is in a screenshot.

I've asked for those passwords many times. I'm a freelance developer and marketer from Hisar, India, and for most of my clients there was no better way to do the work. Sharing a password felt wrong, but nobody offered an alternative that wasn't a pile of setup.

Then I started doing more of this work with an AI agent, and the problem got worse. An agent that can edit DNS or schedule posts needs credentials, and now those credentials end up in prompts and tool configs. The client has no way to see what the agent is about to do, and I have no clean way to prove they said yes.

For the Flip the Switch hackathon I built Keystead to fix this. The idea is short: my AI agent does the work, the client's keys stay with the client, and nothing runs until the client types approve KS-0001 in a shared chat room.

What Switch is

Switch is an open-source project that puts AI agents into team chat. You run a Switch server, and agents join rooms in Slack, Discord, Teams, Telegram or Mattermost. Each agent belongs to a person and acts with that person's access. A room is just a shared conversation where people and agents talk to each other.

The detail that mattered for me is that agents in a room can belong to different people. Most agent tooling I've used assumes one team and one owner. Switch doesn't, and that's what made Keystead possible.

The idea

Keystead puts two agents in one private room.

  • @agency is my Claude Code agent. It does the actual thinking: reading the client's request, deciding what change is needed, and writing it up. It has no client credentials at all.
  • @keystead is a small agent that runs on the client's side. It holds the client's keys in an encrypted vault. It has no LLM. It's plain Python.

A typical exchange looks like this:

client:    can you point the new landing page to the new server?
@agency:   Proposing a DNS change:
           A record  shop.example.com  ->  203.0.113.10  (TTL 300)
@keystead: Proposal KS-0001
           Change:  update A record shop.example.com -> 203.0.113.10
           Policy:  requires approval
           Expires: 30 minutes
           Reply "approve KS-0001" to run it.
client:    approve KS-0001
@keystead: Done. Record updated. Receipt saved, audit entry #1.
Enter fullscreen mode Exit fullscreen mode

The client never logs into anything and never shares a secret. They see exactly what will happen, in a chat they already use, and they say yes in one word.

Why the client's agent has no LLM

This is the design decision I'd defend the hardest.

If the gate is an LLM, someone can talk it into things. A prompt injection in an email, a clever message in the room, or a confused instruction could all push it to approve something it shouldn't. A gate that only reads structured proposals and exact approval commands can't be talked into anything, because there's nothing in it to persuade.

So the split is deliberate. The agency's agent is smart and has no keys. The client's agent has the keys and isn't smart. Neither one alone can do damage.

Rules, not vibes

Before a proposal even reaches the client, @keystead checks it against a policy file. The first matching rule wins, and the default is deny:

rules:
  - action: dns.update
    zone: example.com
    decision: approve        # client must say yes
  - action: dns.delete
    decision: deny           # never, even if asked
  - action: social.post
    decision: approve
    limit_per_day: 3
Enter fullscreen mode Exit fullscreen mode

Then a few checks keep the approval honest:

  • Each proposal gets an ID like KS-0001, and the approval is tied to a hash of the exact payload. If anything changes after the client saw it, the approval doesn't apply.
  • Approvals are single-use and expire after 30 minutes.
  • The approver has to be the client's own account in the room. Approvals from platform messages or from an agent speaking on someone's behalf are rejected.
  • Secrets are redacted from anything the gate posts back to the room.
  • Every action goes into a hash-chained audit log, so editing an old entry breaks the chain.

Setting up Switch

Getting Switch running took me about ten minutes. I ran the server with Docker, opened the Console, and created a room. From there it's two things: connect your own agent, and register a custom one.

The agency side

My agent is Claude Code connected through Switch. The room is defined with a template so the setup is repeatable for every new client:

# keystead-room.template.yaml (simplified)
params:
  client_name:
    required: true
agents:
  - name: agency
    kind: claude-code
room:
  name: "Keystead: {{ client_name }}"
kickoff: |
  Propose changes as a JSON block. Never ask for credentials.
Enter fullscreen mode Exit fullscreen mode

The kickoff message is the important part. It tells the agent that its only job is to propose changes in a fixed format, and that it must never ask for or handle credentials. If it needs something done, it writes a proposal and waits.

The client side

@keystead is a custom Switch agent. The protocol is simple enough that this is a small program: register once, listen to a stream of events, and post back over HTTP.

# simplified sketch of the gate's loop
register_agent(token=REGISTRATION_TOKEN)
for event in sse_stream(room):
    if is_proposal(event):
        decision = policy.check(event.proposal)
        if decision == "approve":
            post_card(event.proposal)
    elif is_approval(event) and approver_matches(event):
        run_connector(find_proposal(event))
        post_receipt()
Enter fullscreen mode Exit fullscreen mode

The connectors are thin wrappers around real APIs: Cloudflare DNS to start, and a social posting service for scheduled posts. Each one only runs after the gate has checked the policy, the proposal ID, the payload hash and the approver.

One gotcha worth knowing

Switch has a built-in permission prompt: allow once, allow for the session, or reject. I first assumed I could use it as the approval step. That would be a mistake for this use case.

That prompt is meant for a single owner approving their own agent's tool use. It isn't a cross-organization approval, because calls to an agent's registered tools don't go through it. If you're building anything where one person's agent acts on another person's behalf, build the approval step yourself and tie it to a specific identity. That's why Keystead uses its own approve KS-NNNN command.

What I liked, and what I'd change

Rooms with agents owned by different people are the best part of Switch, and I think it's underrated. It turns a chat room into a shared workspace between companies, not only inside one team. The agent protocol is also small enough that a custom agent is an afternoon of work, not a project.

On the wish list: a first-class way to mark a message as a formal approval that only a named person can give, and clearer docs on adding a client-owned agent to a room template. I'd also like to see per-room agent permissions in the Console.

What's next

Keystead covers DNS and social posting today. The pattern works for anything an agency does on a client's behalf: ad accounts, email tools, hosting panels, analytics access. Each is a new connector behind the same gate.

The same idea could also work as a hosted service for agencies, with each client getting their own gate. I haven't built that, and if I go that way I'll need to check Switch's license terms around commercial use first.

Try it

Built for the Flip the Switch hackathon by WeMakeDevs. If you've ever pasted a client's password into a chat, I'd like to hear how you handle it now.

Top comments (0)