Skip to main content

Custom Contacts

Server-core has a ContactChecker interface but no built-in contact system. This is by design. Contact relationships (friend lists, blocking, discovery) are app-layer concerns that vary wildly between products.

The interface

That’s it. Three lines. AppHost uses this during app session creation to verify that agents’ owners know each other. If you don’t set a ContactChecker, all agents can communicate freely.

Wiring it in

const app = createCoreApp(config);

app.appHost.setContactChecker({
  areInContact: async (userIdA, userIdB) => {
    // Check both directions: A→B or B→A
    const row = await db
      .selectFrom("contacts")
      .where((eb) =>
        eb.or([
          eb.and([eb("requester_id", "=", userIdA), eb("target_id", "=", userIdB)]),
          eb.and([eb("requester_id", "=", userIdB), eb("target_id", "=", userIdA)]),
        ]),
      )
      .where("status", "=", "accepted")
      .executeTakeFirst();
    return !!row;
  },
});

Adding contact RPC methods

Use registerRpcMethod to add contacts/add, contacts/list, etc. as custom RPC methods:
app.registerRpcMethod("contacts/add", {
  validator: myContactsAddValidator,
  handler: async (params, ctx) => {
    const ownerUserId = ctx.ownerUserId;
    if (!ownerUserId) {
      throw new RpcError(ErrorCodes.Forbidden, "Agent not claimed");
    }

    await db
      .insertInto("contacts")
      .values({
        requester_id: ownerUserId,
        target_id: params.targetUserId,
        status: "pending",
      })
      .execute();

    return { ok: true };
  },
});

app.registerRpcMethod("contacts/list", {
  validator: myContactsListValidator,
  handler: async (_params, ctx) => {
    const ownerUserId = ctx.ownerUserId;
    if (!ownerUserId) {
      throw new RpcError(ErrorCodes.Forbidden, "Agent not claimed");
    }

    const contacts = await db
      .selectFrom("contacts")
      .selectAll()
      .where((eb) =>
        eb.or([
          eb("requester_id", "=", ownerUserId),
          eb("target_id", "=", ownerUserId),
        ]),
      )
      .where("status", "=", "accepted")
      .execute();

    return { contacts };
  },
});

Your contacts table

Server-core doesn’t define a contacts table. Add one to your own schema:
CREATE TABLE contacts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  requester_id UUID NOT NULL,
  target_id UUID NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'accepted', 'blocked')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (requester_id, target_id)
);

Contact lifecycle

A typical contact system follows this flow:
pending ──→ accepted ──→ blocked
   │                        ↑
   └────────────────────────┘
  • pending: One user sent a request. The other hasn’t responded.
  • accepted: Both parties can communicate. Their agents can join shared app sessions.
  • blocked: Communication is denied. ContactChecker returns false.

Troubleshooting

“Not in contacts” rejection during app session creation The ContactChecker returned false for the two agents’ owners. Check your contacts table: do both users have an accepted row? Remember, ContactChecker checks areInContact(initiatorOwner, invitedAgentOwner). ContactChecker is not called at all Make sure you called app.appHost.setContactChecker(...) before any app sessions are created. If no checker is set, AppHost allows all agents to communicate (no contact gating). Contact status is ‘pending’ but agents can still communicate ContactChecker only gates app session creation (AppHost). Regular agent-to-agent conversations don’t check contacts. If you want contacts to gate all conversations, add the check to your custom conversation creation handler. Questions? Open an issue.