Documento aquí, completo y sin TODOs, el proyecto Phipes.Assistant:
la infraestructura que me permite tener un asistente personal autónomo operando dentro de mi
tenant Microsoft 365 — leyendo y respondiendo Teams, correo y WhatsApp por su cuenta, usando
Claude Code
en modo --print headless como motor de razonamiento. Lo que más me interesa contar en esta
versión no es que responda mensajes, sino que relaciona conversaciones y contactos entre canales:
la misma persona escribiéndome por correo, por Teams y por WhatsApp termina siendo un solo contacto con
un solo historial. El proyecto es open source bajo MIT y vive en
github.com/MrPhipes/Phipes.Assistant.
Motivación
Quería resolver cuatro cosas a la vez:
- Tener una identidad delegada que pudiera leer, responder y agendar en mi nombre, con trazabilidad clara — no un bot anónimo posteando desde mi propia cuenta.
- Que el asistente pudiera razonar con el contexto completo de cada conversación, no plantillas. Esto descartó respuestas canned: si suena a bot, rompe la utilidad.
- Que unificara mis canales. Mi vida de mensajes está partida en correo, Teams y WhatsApp; quería que el asistente viera a la persona, no el canal.
- Que todo viviera en mi homelab, sobre infraestructura que yo controlo (Windows Server on-premise, IP dinámica, SQL Server, IIS), sin servicios externos pagos más allá del tenant M365 y el plan Claude.
Arquitectura general
Cuatro servicios independientes que comparten infraestructura mínima:
- Phipes.Assistant.DdnsWorker — Windows Service que mantiene los registros DNS apex de mis dominios apuntando a la IP pública dinámica de la casa.
- Phipes.Assistant.WebhookHandler — IIS site que recibe notifications de Microsoft Graph (Teams chat + Mail), las descifra, las despacha a
claude.exe --printy postea las respuestas al canal correspondiente. - Phipes.Assistant.TokenBroker — Windows Service que aísla el refresh token de M365 bajo su propia cuenta de servicio y entrega solo access tokens de corta vida.
- WhatsApp bridge — proceso Node (Baileys) vinculado a mi número personal en modo solo lectura, que capta los mensajes nuevos y los entrega al pipeline de derivación.
┌────────────────────────┐ ┌──────────────────┐
│ Microsoft 365 (cloud) │ │ WhatsApp (Web) │
│ Teams + Mail + Graph │ │ número personal │
└───────────┬────────────┘ └─────────┬────────┘
│ webhook │ Baileys (read)
▼ ▼
┌──────────────┐ webhook ┌──────────────────────┐ ┌──────────────────┐
│ Internet ├──────────►│ IIS site │ │ WhatsApp bridge │
│ (DDNS via │ │ WebhookHandler │ │ (Node) │
│ Cloudflare) │ │ • Descifra payload │ │ • staging local │
└──────┬───────┘ │ • Valida JWT │ └─────────┬────────┘
│ │ • Dedupe (MSSQL) │ │ derivación
│ │ • Invoca claude.exe │ ▼
│ │ • Postea reply │ ┌──────────────────┐
│ └─────┬──────────┬─────┘ │ Agenda (MSSQL) │
│ │ │ GET /token │ Contacts + │
│ ▼ ▼ │ Channels + │
│ ┌──────────────────┐ ┌─────────────┐ │ Interactions │
│ │ claude.exe │ │ TokenBroker │ └─────────┬────────┘
│ │ --print + skills │ │ (aísla RT) │ │
│ │ + agenda unificada◄┼─────────────┘ ◄──────────┘
│ └──────────────────┘ conversaciones + contactos
▼
┌──────────────────┐
│ DdnsWorker │ • Lee IP pública • Actualiza apex DNS en Cloudflare
└──────────────────┘
Identidad y honestidad
La pieza no-técnica más importante del proyecto. El asistente no es un bot anónimo: tiene su propio UserPrincipalName en Entra ID, mailbox propio, calendario propio, presencia Teams propia, y licencia Business Basic. Sobre esa identidad se otorgan permisos delegados explícitos:
Connect-ExchangeOnline
# Full mailbox access for the assistant over the principal's mailbox
Add-MailboxPermission `
-Identity '[email protected]' `
-User '[email protected]' `
-AccessRights FullAccess `
-InheritanceType All `
-AutoMapping $true
# Calendar delegate (Editor + Delegate flag) over principal's calendar
Add-MailboxFolderPermission `
-Identity '[email protected]:\Calendario' `
-User '[email protected]' `
-AccessRights Editor `
-SharingPermissionFlags Delegate
Dos decisiones deliberadas sobre cómo se comporta esa identidad frente a terceros:
- No se otorga
Send-As. Cuando el asistente responde correos del principal, envía desde su propia identidad con CC al principal; cuando agenda, crea el evento en su propio calendario invitando al principal como required. Toda acción queda registrada bajo elUserPrincipalNamedel asistente, no bajo el del principal. - Honestidad sobre su naturaleza. El asistente no anuncia "soy una IA" en cada mensaje —ningún asistente humano lo haría—, pero si alguien le pregunta directamente, no lo niega. No sostiene la farsa de ser una persona física. Lo que sí protege con rigor es la información de terceros, no su propia naturaleza.
Seguridad: mínimo privilegio y defensa fuera del LLM
Darle a un agente autónomo acceso delegado a tu correo y tus chats obliga a tomarse la seguridad en serio. Tres principios concretos:
TokenBroker: separación de privilegios a nivel de SO
El refresh token (RT) de M365 es la credencial más sensible del sistema. En lugar de dejar que el proceso del handler —o la propia instancia de Claude Code que corre como ese usuario— pueda leerlo, lo aíslo en un servicio aparte que corre bajo otra cuenta de servicio. El cred.xml del RT se cifra con DPAPI bajo el SID de esa cuenta; por ACL, ningún otro usuario puede descifrarlo.
El handler y los skills nunca ven el RT: piden un access token de corta vida al broker por HTTP en loopback, autenticando con un header secreto. El broker es el único que rota el RT, lo que además elimina la clase de bug de "dos procesos comparten el mismo XML y se desincronizan".
// El handler resuelve el provider por config: si hay broker, lo usa; si no,
// cae al lector directo del cred.xml (migración gradual).
if (!string.IsNullOrWhiteSpace(brokerUrl))
services.AddHttpClient<IGraphTokenProvider, BrokerGraphTokenProvider>();
else
services.AddHttpClient<IGraphTokenProvider, GraphTokenProvider>();
// El access token se inyecta a claude.exe como variable de entorno; los skills
// (PowerShell) lo leen de ahí y NUNCA tocan el cred.xml en disco.
psi.Environment["M365_ACCESS_TOKEN"] = await _graphTokenProvider.GetAccessTokenAsync(ct);
Hook guardrail: una barrera que el modelo no puede saltar
Aprendí por las malas que las reglas a nivel de prompt son útiles pero no suficientes: un agente autónomo puede equivocarse e intentar una operación destructiva sobre su propia configuración o sus secretos. La respuesta correcta es defensa fuera del LLM. Un hook PreToolUse intercepta y bloquea cualquier operación destructiva sobre rutas sensibles —la carpeta de secretos, settings.json, los propios hooks, el archivo de memoria y los límites de política— antes de que se ejecute. El agente puede pedirlo; el sistema operativo dice que no.
Scopes mínimos
La app pide solo los permisos delegados que los skills realmente usan (correo, calendario, chats, tareas, presencia, archivos del propio asistente). Nada de permisos de directorio o de invitación de usuarios "por si acaso". Menos superficie, menos blast radius si algo falla.
Los tres canales de entrada
Correo
Una subscription de Microsoft Graph sobre /me/messages notifica cada correo entrante. El handler lo descifra, descarta los no-reply y aquellos donde el asistente no está en el TO, e invoca a Claude. El asistente decide si responde, si solo lo registra, o si lo deja para que yo lo vea ([SKIP]). Gracias al FullAccess delegado, también puede buscar en el historial del mailbox para dar contexto.
Teams
Una subscription sobre los chats notifica cada mensaje. El asistente mantiene continuidad de sesión por chat: cada chatId mapea a un thread propio de Claude Code que sobrevive a reinicios. Si le escribo "agenda una reunión con un cliente mañana a las 16:00", lee el mensaje, invoca su skill de agenda, crea el Teams meeting en su calendario invitando a los participantes, y confirma — todo en un turno.
Este es el canal nuevo y el más delicado. WhatsApp no tiene una API oficial para leer una línea personal, así que uso un bridge basado en Baileys (WhatsApp Web) vinculado a mi número, en modo solo lectura. El bridge no guarda el texto crudo de los mensajes: los deja en un staging transitorio y un proceso de derivación los convierte en hechos antes de purgarlos.
El resultado: el asistente sabe "el cliente X me preguntó por el avance del proyecto Y, sigue pendiente" sin que el contenido literal del chat de un tercero quede almacenado. La privacidad se modela en anillos: la información de terceros se usa para asistirme a mí, no se comparte ni se expone, y solo persiste como hecho derivado.
La inteligencia: una agenda que relaciona conversaciones y contactos
Acá está lo que de verdad hace útil al asistente. Cada canal, por sí solo, es un buzón más. El valor aparece cuando todos los canales alimentan una sola agenda y esa agenda entiende que detrás de tres identidades técnicas distintas hay una sola persona.
El modelo de datos
Cuatro tablas en SQL Server local resuelven el problema:
Contacts— la persona real (un nombre canónico, notas, su relación conmigo).ContactChannels— las identidades técnicas de esa persona: su correo, su id de Teams, su número/@lidde WhatsApp. Varios canales apuntan a un mismo contacto.Interactions— cada conversación o mensaje relevante, derivado a un hecho (qué se habló, qué quedó pendiente, cuándo), con su canal de origen.InteractionContacts— el puente N:N entre interacciones y contactos (un mismo hilo puede involucrar a varias personas).
-- Una persona, varias identidades técnicas
Contact(Id, DisplayName, Notes, ...)
ContactChannel(Id, ContactId, ChannelType, ChannelKey) -- 'email' | 'teams' | 'whatsapp'
Interaction(Id, ChannelType, OccurredAt, DerivedFact) -- hecho derivado, no texto crudo
InteractionContact(InteractionId, ContactId) -- N:N
Fusión de identidad cross-canal
El paso interesante es decidir que contacto-whatsapp-49xx, [email protected] y un id de Teams son la misma persona. La heurística combina coincidencia por nombre, por dominio/correo conocido y por confirmación manual cuando hay ambigüedad. Una vez fusionados, una pregunta como "¿qué tengo pendiente con este cliente?" cruza correo + Teams + WhatsApp y devuelve una sola ficha con el historial completo, ordenado en el tiempo, sin importar por dónde llegó cada mensaje.
Una regla de presentación que me importa: el asistente nunca me responde con un identificador crudo (un @lid de WhatsApp, un GUID de Teams). Siempre traduce al nombre del contacto. Si el nombre es pobre (iniciales, un pushName genérico), me lo dice y me ofrece enriquecerlo — "el de WhatsApp 'RM' lo registré como Roberto, ¿es Roberto Méndez?".
WhatsApp en detalle: de un @lid opaco a un contacto con historial
WhatsApp es donde la integración relacional se nota más, porque es el canal que llega más "ciego": un mensaje entrante no trae un correo ni un nombre confiable, sino un identificador opaco —el @lid— y, con suerte, un pushName que la persona eligió y que puede ser cualquier cosa. Convertir eso en algo útil es justamente el trabajo de la agenda relacional.
El flujo corre cada pocos minutos y nunca persiste texto crudo de terceros:
- El bridge capta los mensajes nuevos y los deja en un staging local transitorio (un
jsonl), sin escribirlos a la agenda todavía. - Un derivador (que invoca a Claude Code) lee ese staging y, por cada conversación, produce un hecho: qué se pidió, qué quedó pendiente, cuándo — no la transcripción.
- El derivador resuelve el contacto: busca el
@lidenContactChannels. Si ya existe, ata el hecho a ese contacto; si no, crea el canal y lo asocia al mejor candidato por nombre, o lo deja como contacto provisional para que yo lo confirme. - Escribe la
Interactiony su vínculo enInteractionContactscon el nombre del contacto, y purga el staging.
La pieza relacional es el paso 3. Un @lid de WhatsApp se liga al mismo Contact que esa persona ya tenía por su correo y su Teams. Desde ese momento, un ping de WhatsApp enriquece la ficha existente en lugar de crear un tercer registro huérfano: el historial de esa persona es uno solo, alimentado por los tres canales.
WhatsApp @lid:5x9a… (pushName: "RM")
│ derivación → hecho: "pregunta por el avance del proyecto Z; pendiente"
▼
ContactChannel(whatsapp, '5x9a…') ─┐
ContactChannel(email, '[email protected]') ─┼──► Contact #42 "Roberto Méndez"
ContactChannel(teams, 'aad-id…') ─┘ ├─ Interaction (whatsapp) hoy
├─ Interaction (email) hace 5 días
└─ Interaction (teams) hace 2 semanas
Un ejemplo concreto: llega un WhatsApp de "RM" preguntando por un proyecto. El derivador lo ata al contacto Roberto Méndez —ya conocido por correo— y registra el hecho. Cuando después le pregunto al asistente "¿qué tengo pendiente con Roberto?", la respuesta cruza ese WhatsApp de hoy con el correo de la semana pasada y la reunión de Teams de hace dos semanas: una sola ficha, ordenada en el tiempo, sin importar por dónde entró cada mensaje. Y si el @lid no calza con nadie, el asistente no inventa: me dice "me escribió 'RM' por WhatsApp, no lo tengo fichado, ¿quién es?" y lo enriquezco con una frase.
Por qué esto cambia la experiencia
Sin la agenda, tendría tres asistentes ciegos entre sí. Con ella, cuando alguien me escribe por WhatsApp pidiendo algo que ya habíamos conversado por correo la semana pasada, el asistente lo sabe. Puede prepararme un resumen del día que junta "quién me escribió y qué quiere" a través de los tres canales. Y cuando agendo o respondo, lo hace con el contexto de toda la relación, no de un solo hilo.
Skills del asistente
El asistente descubre automáticamente sus skills (cada una es un SKILL.md que lee como instrucciones en lenguaje natural). Las que usa a diario:
| Skill | Hace | |
|---|---|---|
/buscar-mail | Busca en el mailbox (search de Microsoft Graph). | |
/enviar-correo | Envía desde el asistente o crea un draft en Borradores. | |
/leer-cal | Lista los eventos del calendario en hora local. | |
/agendar | Crea un Teams meeting e invita a los participantes. | |
/wsp | Deriva los mensajes de WhatsApp a hechos y los liga a contactos de la agenda. | |
/sql | Consulta la agenda y la tabla de idempotencia en SQL Server. | |
/handler-log | Lee y filtra el log diario del webhook handler. | |
/subscriptions-status | Lista las subscriptions de Graph activas y su tiempo de vida. |
Lecciones aprendidas
Las cosas que cuestan horas si uno no las anticipa, en lista plana:
- Las reglas de prompt no bastan para un agente autónomo. Si el agente puede tocar sus propios secretos o config, necesitas una barrera fuera del LLM (hook a nivel de SO).
- Aísla el refresh token en otro proceso/usuario. Un proceso separado bajo otra cuenta, dueño único del RT, es la mejor defensa contra que el agente lo corrompa o lo filtre.
- Microsoft rota refresh tokens en cada uso. Si dos procesos comparten el mismo
cred.xml, se desincronizan y uno falla coninvalid_grant. Designa un único consumidor por XML — otra razón para el broker. - WhatsApp Web no oficial es frágil y sensible. Úsalo en solo lectura, no guardes texto crudo de terceros, y deriva a hechos lo antes posible. Trata el staging como transitorio.
- La fusión de identidad necesita un humano en el bucle. Auto-fusionar por nombre genera falsos positivos; deja que el dueño confirme las ambigüedades y enriquezca los contactos pobres.
- Nunca devuelvas IDs crudos al usuario. Un
@lido un GUID no significan nada para una persona; traduce siempre al nombre del contacto. - Si Claude falla, silencio. Nunca respondas con texto canned tipo "recibí tu mensaje": cualquiera detecta el bot al instante. Es preferible dejar el mensaje en visto e intervenir a mano.
- Microsoft Graph entrega duplicados. Idempotencia con una PK
(SubscriptionId, ResourceId)en SQL: INSERT antes de procesar; si choca, ya estaba hecho. - Las subscriptions de Graph expiran y a veces "callan". Un
BackgroundServiceque hace PATCH preventivo cada 30 min las mantiene vivas y reactiva el delivery. - PowerShell 5.1 + UTF-8 + tildes:
Invoke-RestMethodserializa como Windows-1252 cuando el body es string. Para POST/PATCH JSON con acentos, fuerza UTF-8 víaHttpWebRequestmanual.
Repositorio
Todo el código está en github.com/MrPhipes/Phipes.Assistant, bajo licencia MIT. El README.md incluye el setup completo paso a paso (App Registration, permisos Exchange, MSSQL, IIS, TokenBroker, User Secrets, creación de subscriptions). Si lo va a usar para su propio asistente, sustituya los placeholders midominio.cl, principal@, assistant@ por sus valores.
Qué viene
Cosas pendientes que iré documentando en posts siguientes:
- Tool use definitions explícitas en lugar de heredar los tools nativos de Claude Code, para acotar aún más el blast radius del asistente autónomo.
- Workflow de transcripts de reuniones para generar minutas automáticas y atribuirlas a los contactos correctos en la agenda.
- Enriquecimiento proactivo de contactos: que el asistente proponga fusiones y complete fichas a partir de lo que observa, siempre con mi confirmación.
- Más canales a la misma agenda, manteniendo el principio de hechos derivados y privacidad por anillos.
Si está pensando en construir algo similar y quiere conversar, escríbame.
Comentarios
Aún no hay comentarios. Sé el primero en compartir tu opinión.