Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ GOOGLE_CONNECTOR_UID=
# optional click-to-message shortcut in the workspace.
LINQ_CONNECTOR=
LINQ_PHONE_NUMBER=
# Optional Blooio iMessage provider (https://api.blooio.com). When Linq is
# unset, sign-in codes are sent through Blooio. Point a v4 webhook at
# https://<your-host>/webhooks/blooio and store its signing secret here.
BLOOIO_API_KEY=
BLOOIO_WEBHOOK_SECRET=
BLOOIO_FROM_NUMBER=
# Optional per-user Link wallet connection. Register the exact callback URL
# https://<BETTER_AUTH_URL host>/api/auth/callback/link with Stripe.
LINK_CLIENT_ID=
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,27 @@ but cannot receive them.

</details>

### Blooio iMessage

[Blooio](https://blooio.com) is an optional iMessage provider. Leave it unset to
keep the Linq line from the deploy button. When `LINQ_CONNECTOR` is unset and
`BLOOIO_API_KEY` is set, sign-in codes and the assistant's iMessage thread use
Blooio's [v4 API](https://api.blooio.com).

1. Create an API key in the Blooio dashboard.
2. Set `BLOOIO_API_KEY`. Set `BLOOIO_FROM_NUMBER` when the key can send from
more than one number; otherwise OpenInstinct uses the key's active number.
3. Create a webhook for `https://<your-host>/webhooks/blooio` and set
`BLOOIO_WEBHOOK_SECRET` to the signing secret returned at creation.
Unsigned deliveries are rejected.
4. Redeploy and sign in with your phone number. After the code verifies, text
the Blooio number from that phone. OpenInstinct ignores messages from
numbers that are not linked to a verified account.

Scheduled reminders created in a Blooio thread are delivered back to that
thread. Linq remains the path used for sign-in when both providers are
configured.

## Google Workspace connection

OpenInstinct can use a user's Gmail, Calendar, and read-only Contacts through a
Expand Down
111 changes: 111 additions & 0 deletions agent/channels/blooio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { defineChannel, POST } from "eve/channels";
import { z } from "zod";
import { markBlooioChatRead } from "@shared/blooio/api";
import {
acceptBlooioWebhook,
blooioInboundAuth,
blooioUserContent,
claimBlooioDelivery,
} from "@agent/lib/blooio/inbound";
import {
deliverBlooioAction,
deliverBlooioAuthorization,
finishBlooioTurn,
releaseBlooioTurn,
showBlooioTyping,
} from "@agent/lib/blooio/deliver";

const receiveTargetSchema = z.object({
conversationId: z.string().startsWith("blooio:"),
});

export default defineChannel({
audience({ caller }) {
return caller.type === "principal" && caller.principal.kind === "user"
? "private"
: "unknown";
},
events: {
"action.result"(event, channel, session) {
return deliverBlooioAction(event, channel, session);
},
async "authorization.required"(event, channel, session) {
await deliverBlooioAuthorization(event, channel, session);
},
"message.completed"(event, _channel, session) {
return finishBlooioTurn(event, session);
},
"session.completed"(_event, _channel, session) {
return finishBlooioTurn({}, session);
},
"turn.cancelled"(_event, _channel, session) {
return releaseBlooioTurn(
session,
"Scheduled result reporting was cancelled."
);
},
"turn.failed"(event, _channel, session) {
return releaseBlooioTurn(session, event.message);
},
"turn.started"(_event, channel, session) {
return showBlooioTyping(channel, session);
},
},
async fetchFile(url) {
if (!url.startsWith("https://")) return null;
const response = await fetch(url);
if (!response.ok) return null;
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.byteLength > 10_000_000) return null;
const mediaType = response.headers
.get("content-type")
?.split(";")[0]
?.trim();
return mediaType ? { bytes, mediaType } : bytes;
},
async receive(input, { from }) {
const target = receiveTargetSchema.parse(input.target);
return from(target.conversationId.slice("blooio:".length)).send(
input.message,
{ auth: input.auth }
);
},
routes: [
POST("/webhooks/blooio", async (request, { from, waitUntil }) => {
const accepted = await acceptBlooioWebhook(request);
if (!accepted.ok) return accepted.response;
const message = accepted.message;
if (!message) return new Response("ok");
const auth = await blooioInboundAuth(message);
if (!claimBlooioDelivery(message.messageId)) return new Response("ok");
if (!auth) {
console.warn("[blooio] ignoring message from an unlinked handle", {
chatId: message.chatId,
});
return new Response("ok");
}
// Blooio retries deliveries that take longer than five seconds. Accept
// the webhook first and run the agent turn after the response.
waitUntil(
(async () => {
try {
await markBlooioChatRead(message.chatId);
} catch {
// A missed read receipt should not drop the inbound turn.
}
try {
await from(message.chatId).send(blooioUserContent(message), {
auth,
});
} catch (error) {
console.warn("[blooio] inbound delivery failed", {
error,
messageId: message.messageId,
});
}
})()
);
return new Response("ok");
}),
],
});
17 changes: 1 addition & 16 deletions agent/channels/linq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { vercelOidc } from "eve/channels/auth";
import { z } from "zod";
import { resolveLinqReplyTarget } from "@agent/lib/reply-targets";
import { scopeFromPrincipal } from "@agent/lib/principal-scope";
import { getAuth } from "@db/services/auth";
import { findVerifiedAuthUserIdByPhoneNumber } from "@db/services/auth/verified-phone";
import { sendMessageToolResultSchema } from "@shared/chat/message-delivery";
import { reactToMessageToolResultSchema } from "@shared/chat/reaction";
import { accessScopeForUser } from "@shared/identity/access-scope";
Expand All @@ -30,10 +30,6 @@ import {
scheduledReportFromSession,
} from "@agent/lib/schedules/report-lifecycle";

const verifiedPhoneUserSchema = z.object({
id: z.string().min(1),
phoneNumberVerified: z.literal(true),
});
const unavailableReplyTargetSchema = z.object({
status: z.union([z.literal(400), z.literal(404)]),
});
Expand Down Expand Up @@ -403,14 +399,3 @@ async function sendLinqMessage({
await post(outgoing);
}
}

async function findVerifiedAuthUserIdByPhoneNumber(phoneNumber: string) {
const auth = await getAuth();
const context = await auth.$context;
const user = await context.adapter.findOne({
model: "user",
where: [{ field: "phoneNumber", value: phoneNumber }],
});
const parsed = verifiedPhoneUserSchema.safeParse(user);
return parsed.success ? parsed.data.id : undefined;
}
6 changes: 3 additions & 3 deletions agent/instructions/content/role/interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ The main conversation is the control plane. Coordinate the user's work there and

# Coordination

- On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq renders reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible.
- On every user-initiated conversational turn, use `send_message`, or use `react_to_message` alone when a lightweight reaction fully answers the user and words would add nothing. Use `share_contact` for an introduction with OpenInstinct's saveable contact or when asked for it; that call delivers both the introduction and attachment. Linq and Blooio render reactions as native Tapbacks; other supported conversations render a compact reaction. Ordinary assistant text is internal and is never user-visible.
- Use `schedules-create` to create a one-time reminder, recurring job, monitor, or scheduled follow-up. Use `kind: "calendar"` with the user's IANA timezone for wall-clock recurrence, `kind: "interval"` for elapsed intervals, and `kind: "once"` for one future instant. Summarize the exact task in `prompt`. Use `schedules-list` before changing an ambiguous schedule and `schedules-update` to edit, pause, resume, or delete it.
- When the user answers a question previously sent for a scheduled task, call `schedules-answer` with the internal run ID retained in conversation context and their answer. The parked background run continues from the exact point where it asked.
- Use the full native Linq/iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` can add or remove any supported Tapback on the current user message: `thumbs_up`, `thumbs_down`, `heart`, `laugh`, `exclamation` (emphasis), or `question`.
- Eve and Linq own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation.
- Use the full native Linq or Blooio iMessage surface when it helps. Choose `kind: "message"` for plain text, exact worker artifact references, and HTTPS attachments; text and attachments may be combined. Choose `kind: "link"` with `url` for a standalone native rich link-preview card, or put a URL in message text for a plain tappable URL. `react_to_message` can add or remove any supported Tapback on the current user message: `thumbs_up`, `thumbs_down`, `heart`, `laugh`, `exclamation` (emphasis), or `question`.
- Eve, Linq, and Blooio own read receipts, typing indicators, delivery state, authorization prompts, and input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation.
- After a `send_message`, `share_contact`, or `react_to_message` call, never repeat or summarize it in assistant text. If the runtime requires terminal assistant text after the last delivery, emit only `DELIVERY_COMPLETE`; ordinary assistant text is not delivered to Linq.
- The worker's structured result is coordinator-facing only. Rewrite it into a concise user-facing response; never imply that the worker spoke to the user.
- Start a background worker without a separate preamble. Once its working receipt arrives, send one short acknowledgment saying what is underway. Treat the receipt as acceptance, not completion.
Expand Down
Loading