Infraestructura para un asistente personal autónomo que opera con identidad propia dentro de Microsoft 365 (Teams + Mail), suma WhatsApp como canal de contexto, y se ayuda de Claude Code corriendo en modo headless para razonar y responder — relacionando las conversaciones y los contactos de los distintos canales en un solo hilo. Pensado para una casa/oficina pequeña con un Windows Server on-premise, una conexión a internet con IP dinámica, y un mailbox Microsoft 365 dedicado para el asistente.
Licencia MIT. Hecho por Felipe Hernández. Úselo libremente, manteniendo el aviso de copyright.
Tres servicios independientes que comparten infraestructura mínima:
-
Phipes.Assistant.DdnsWorker— Mantiene los registros DNS apex de uno o más dominios apuntando a la IP pública dinámica de la casa/oficina. Útil cuando se hostean servicios en infraestructura propia detrás de un router/firewall con IP que cambia. -
Phipes.Assistant.WebhookHandler— Recibe notificaciones de Microsoft Graph (Teams chat + Mail) sobre la cuenta del asistente, las descifra, las despacha a Claude Code en modo--print, y postea las respuestas al canal correspondiente. Es lo que convierte un mailbox y una cuenta Teams en "una persona que responde sola". Antes de invocar a Claude inyecta contexto relacional (memoria de trabajo y tareas abiertas del interlocutor) y clasifica quién está al otro lado para ajustar el tono y lo que puede revelar. -
Phipes.Assistant.TokenBroker— Aísla el refresh token de Microsoft 365 en un proceso/cuenta de servicio aparte y entrega solo access tokens de corta vida por HTTP en loopback. Así ni el handler ni Claude Code tocan nunca la credencial más sensible. Ver su sección más abajo.
Cada servicio puede usarse por separado.
Canales y relación entre conversaciones. El asistente no solo responde Teams y Mail:
también integra WhatsApp como canal de contexto. La ingesta de mensajes de WhatsApp la
hace un bridge compañero (repo aparte) que deriva los mensajes a hechos y los deja en
la base local; este repositorio aporta el WaProactiveNotifier, que relaciona los
canales — si usted le preguntó al asistente por un contacto y ese mismo contacto le escribe
por WhatsApp poco después, el asistente se lo avisa por Teams. La meta es que detrás de las
distintas identidades técnicas (correo, Teams, WhatsApp) el asistente vea una sola persona
con un solo hilo.
┌────────────────────────┐ ┌───────────────────────────┐
│ Microsoft 365 (cloud) │ │ WhatsApp (bridge │
│ Teams + Mail + Graph │ │ compañero, repo aparte) │
└───────────┬────────────┘ └─────────────┬─────────────┘
│ webhook │ hechos derivados
▼ ▼
┌──────────────┐ webhook ┌────────────────────────────┐ ┌──────────────────────┐
│ Internet ├────────►│ IIS site · WebhookHandler │ │ MSSQL local │
│ (DDNS vía │ │ • Descifra · valida JWT │◄─►│ • Idempotencia │
│ Cloudflare) │ │ • Dedupe │ │ • WorkingMemory │
└──────┬───────┘ │ • Clasifica interlocutor │ │ • WaActiveTopic │
│ │ • Inyecta contexto │ └──────────┬───────────┘
│ │ • Invoca claude.exe │ │
│ │ • Postea reply │ WaProactiveNotifier:
│ └──────┬───────────┬──────────┘ avisa por Teams si el
│ │ │ GET /token contacto consultado
│ ▼ ▼ escribe por WhatsApp
│ ┌──────────────────┐ ┌──────────────────────┐
│ │ claude.exe │ │ TokenBroker │
│ │ --print + skills │ │ (svc-token-broker) │
│ │ + $M365_ACCESS_..│ │ • Único lector del RT│
│ │ inyectado │ │ • Rota y cachea el AT │
│ └──────────────────┘ └──────────────────────┘
│
│ (la misma máquina hostea también:)
▼
┌──────────────────────┐
│ DdnsWorker │ • Lee IP pública
│ │ • Actualiza apex DNS en Cloudflare
└──────────────────────┘
Servicio Windows (.NET 10 Worker Service) que mantiene records DNS A en Cloudflare sincronizados con la IP pública dinámica.
Cómo obtiene la IP pública (con fallback):
- Primario: API local de UniFi Dream Machine (Pro/SE), endpoint
/proxy/network/api/s/default/stat/health, campowan_ipdel subsistema WAN. - Fallback:
api.ipify.org.
Cómo actualiza Cloudflare: API REST oficial,
PATCH https://api.cloudflare.com/client/v4/zones/{zoneId}/dns_records/{recordId}.
Arquitectura DNS recomendada: en lugar de gestionar uno por uno cada subdominio,
mantener actualizados solo los records apex (midominio.cl tipo A). Todos los
subdominios que viven en casa (home.midominio.cl, webhook.midominio.cl,
www.midominio.cl, …) se configuran como CNAME al apex. Un solo cambio de IP propaga
a todos.
Seguridad de credenciales: los tokens de Cloudflare y UniFi se almacenan como
PSCredential XML cifrado con DPAPI (Export-Clixml). Solo el usuario Windows que
cifró el archivo, en la misma máquina, puede descifrarlo. Esto evita tokens en texto
plano.
Tras dotnet user-secrets set desde src/Phipes.Assistant.DdnsWorker/:
| Setting | Ejemplo / propósito |
|---|---|
Ddns:PollIntervalSeconds |
300 (5 min). |
Ddns:Cloudflare:ApiTokenPath |
C:\secrets\cloudflare-api.token.xml |
Ddns:Cloudflare:AccountId |
Account ID de Cloudflare. |
Ddns:Cloudflare:Records:0:Zone |
midominio.cl |
Ddns:Cloudflare:Records:0:ZoneId |
Zone ID de Cloudflare. |
Ddns:Cloudflare:Records:0:Hostname |
midominio.cl (apex). |
Ddns:Cloudflare:Records:0:RecordId |
Record ID del A apex. |
Ddns:Cloudflare:Records:0:Proxied |
false |
Ddns:Unifi:BaseUrl |
https://192.168.1.1 (URL del UDM). |
Ddns:Unifi:ApiTokenPath |
C:\secrets\unifi-api.token.xml |
Ddns:Unifi:VerifyTls |
false para cert self-signed del UDM. |
Ddns:Ipify:Endpoint |
https://api.ipify.org (fallback). |
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Services\Phipes.Assistant.DdnsWorker
sc.exe create Phipes.Assistant.DdnsWorker `
binPath= "C:\Services\Phipes.Assistant.DdnsWorker\Phipes.Assistant.DdnsWorker.exe" `
start= auto `
obj= "<dominio>\<usuario-que-cifro-los-XML>" `
password= "<password>" `
DisplayName= "Phipes Assistant DDNS Worker"
sc.exe start Phipes.Assistant.DdnsWorkerCrítico: obj= debe ser el mismo usuario Windows que cifró los XML DPAPI. Si no,
el servicio falla al descifrar. No usar LocalSystem ni NetworkService.
ASP.NET Core (.NET 10) que recibe notificaciones de Microsoft Graph y orquesta la respuesta del asistente. Corre como IIS site con app pool always-on.
| Componente | Función |
|---|---|
POST /webhook/teams |
Recibe notificaciones de chat y mail (mismo endpoint, ruteado por Resource type). |
POST /webhook/teams/lifecycle |
Recibe eventos reauthorizationRequired, subscriptionRemoved, missed. |
GET /health |
Healthcheck JSON simple. |
NotificationDecrypter |
RSA-OAEP-SHA1 + HMAC-SHA256 + AES-256-CBC (esquema oficial de Graph encrypted resource data). |
JwtNotificationValidator |
Valida validationTokens[] contra JWKS de Microsoft (modo shadow o reject). |
SqlIdempotencyStore |
INSERT con PK (SubscriptionId, ResourceId) en MSSQL local; duplicate-key = dedupe. |
TeamsNotificationHandler |
Procesa mensajes de Teams chat → invoca Claude → postea reply via Graph. |
MailNotificationHandler |
Procesa correos → filtra no-reply / no-en-TO → invoca Claude (que puede decidir [SKIP]). |
LifecycleHandler |
Renueva subscriptions al recibir reauthorizationRequired. |
SubscriptionRenewer |
BackgroundService que cada 30 min hace PATCH preventivo a las subscriptions configuradas. |
ClaudeCodeInvoker |
Spawn claude.exe --print con OAuth long-lived token + perfil personalizado. Inyecta el access token de Graph como $M365_ACCESS_TOKEN para que los skills no lean el cred.xml directo. |
IGraphTokenProvider |
Abstracción del access token de Graph. Dos implementaciones según config (ver Broker:ListenUrl): |
↳ BrokerGraphTokenProvider |
(recomendada) Pide el AT al TokenBroker local por HTTP. El handler nunca toca el refresh token. |
↳ GraphTokenProvider |
(legacy/fallback) Lee el cred.xml directo y maneja refresh + rotación en proceso. |
WebhookAppTokenProvider |
Token de la "Webhook app" propia (la que tiene admin-consent para crear subscriptions). |
TrustRingClassifier |
Clasifica al interlocutor en uno de 4 anillos de confianza (por tenant/dominio/firma del token). |
TrustRingPromptPolicy |
Construye la sección de política de contexto que se inyecta al prompt según el anillo — describe funcionalmente quién es el interlocutor sin revelar que existe un modelo formal (resistente a prompt injection). |
WorkingMemoryReader |
Lee la WorkingMemory + tareas de coordinación abiertas del interlocutor en MSSQL y las inyecta como contexto antes de invocar a Claude. Fail-soft: si MSSQL falla, devuelve vacío. |
WaProactiveNotifier |
BackgroundService: si un contacto por el que usted preguntó (registrado en WaActiveTopic vía /wsp) le escribe por WhatsApp dentro de una ventana, le avisa proactivamente por Teams. Es el puente relacional WhatsApp↔Teams. |
MessageAttachmentExtractor |
Descarga imágenes y adjuntos de mensajes de Teams al filesystem para que Claude los lea con la tool Read. |
SubscriptionRecreator |
Recrea una subscription desde cero al recibir subscriptionRemoved, sin intervención manual. |
SubscriptionStateStore |
Persiste a disco los id de las subscriptions para sobrevivir reinicios del app pool (evita que el Renewer use ids viejos del User Secrets). |
AlertManager |
Dispara alertas por categoría de evento según reglas de monitoreo configurables. |
FileLogger |
Centraliza logging a archivo. Path configurable via env var HANDLER_LOG_DIR. |
El profile compartido en <ClaudeHome>/.claude/skills/ define los skills que el
asistente descubre automáticamente:
| Skill | Hace |
|---|---|
/handler-log |
Lee/filtra el log diario del webhook handler. |
/subscriptions-status |
Lista Graph subscriptions activas y tiempo de vida. |
/sql |
Consulta MSSQL local (tabla de idempotencia, etc.). |
/buscar-mail |
Búsqueda Graph $search en el mailbox del usuario delegante. |
/enviar-correo |
Envía correo desde el asistente o crea draft en Borradores del jefe. |
/leer-cal |
Lista eventos del calendario del jefe en hora local. |
/agendar |
Crea Teams meeting en el calendario del asistente, invita al resto. |
/wsp |
Consulta el contexto de WhatsApp derivado y registra el contacto consultado como tema activo (lo que habilita el aviso proactivo del WaProactiveNotifier). |
El asistente es una identidad real en el tenant Microsoft 365 (mailbox + Teams + calendar propios). El propietario humano le otorga:
FullAccesssobre su mailbox (Add-MailboxPermission) → asistente puede leer y responder correos del jefe.Editor+SharingPermissionFlags=Delegatesobre la carpetaCalendariodel jefe (Add-MailboxFolderPermission) → asistente puede leer su agenda.- NO se otorga
Send-As: el asistente nunca suplanta al jefe en envíos. Cuando responde mails del jefe, envía desde su propio mailbox con CC al jefe; cuando agenda reuniones, las crea en su propio calendario invitando al jefe como required.
Honestidad. El asistente no anuncia "soy una IA" en cada mensaje —ningún asistente lo hace—, pero si alguien le pregunta directamente, no lo niega ni finge ser una persona física. Lo que protege con rigor es la información de terceros, no su propia naturaleza.
Contexto relacional. Antes de responder, el handler clasifica al interlocutor
(TrustRingClassifier) y le inyecta a Claude lo que sabe de esa persona —memoria de trabajo,
tareas abiertas y, si aplica, su actividad reciente por WhatsApp— de modo que el asistente
trate a cada interlocutor como un contacto con historial, no como un mensaje aislado. La
narrativa de esa política se describe funcionalmente y nunca expone el modelo interno al
interlocutor.
Servicio Windows (.NET 10) que aísla el refresh token (RT) de Microsoft 365 en un
proceso aparte, corriendo bajo su propia cuenta de servicio (p. ej. svc-token-broker).
Es la fuente única del access token (AT) de Graph para todo el sistema.
El RT es la credencial más sensible del proyecto: con él se obtiene acceso delegado al
mailbox, calendario y Teams del jefe. Si el cred.xml que lo contiene fuera legible por
el proceso del handler (o por la propia instancia de Claude Code que corre como ese
usuario), un fallo de comportamiento del agente podría leerlo, corromperlo o borrarlo.
La solución es separación de privilegios a nivel de sistema operativo:
- El
cred.xmldel RT se cifra con DPAPI scope CurrentUser bajo el SID desvc-token-broker. Por ACL, ningún otro usuario (nisvc-webhook-handler, ni la instancia de Claude Code) puede leerlo ni descifrarlo. - El handler y los skills nunca ven el RT. Piden un AT de corta vida al broker por HTTP en loopback, y reciben solo eso.
- Como el broker es el único que rota el RT, se elimina la clase de bug de
"dos procesos comparten el mismo
cred.xmly se desincronizan" (invalid_grant).
| Endpoint | Función |
|---|---|
GET /token |
Devuelve un AT válido ({access_token, expires_at_utc}). Refresca si el cache está por vencer. |
POST /refresh |
Fuerza una rotación inmediata del RT. |
GET /health |
Estado sin exponer el AT (last refresh, expiración, contador, último error). |
Kestrel escucha solo en loopback; además, cada request debe traer el header
X-Broker-Secret (comparado en tiempo constante) o recibe 401. Es una defensa contra
otros procesos del mismo host que descubran el puerto. Un BackgroundService interno
refresca el RT proactivamente cada 30 min, de modo que una revocación se detecta en el
próximo tick y no cuando un usuario está esperando respuesta.
El WebhookHandler resuelve IGraphTokenProvider según config: si Broker:ListenUrl
está definido usa BrokerGraphTokenProvider (pide el AT al broker); si no, cae al
GraphTokenProvider legacy (lee el cred.xml directo). Esto permite migración gradual.
El AT obtenido se inyecta a claude.exe como variable de entorno M365_ACCESS_TOKEN,
así los skills (PowerShell) lo usan sin tocar disco.
Configuración: ver sección
Brokerdeappsettings.json. Los valores sensibles (CredXmlPath,TenantId,Secret) van vacíos en el repo y se inyectan vía User Secrets o variables de entorno del servicio. ElScopedeclara el conjunto de permisos delegados mínimos que usan los skills.
- Windows Server (probado en 2022) o Windows 11.
- .NET 10 SDK (versión exacta en
global.json). - IIS con módulo ASP.NET Core (in-process hosting) y Application Initialization
feature instalada (
Install-WindowsFeature Web-AppInit). - PowerShell 7 (
pwsh.exe) o Git for Windows, requerido por Claude Code en Windows. - SQL Server (cualquier edición, incluso Express; probado en 2022) para la tabla de idempotencia.
- Cuenta Microsoft 365 dedicada para el asistente, con licencia que incluya Exchange Online y Teams (Business Basic alcanza).
- Acceso a Entra ID (Azure AD) para crear una App Registration con permisos delegados.
- Suscripción Claude (Anthropic) para Claude Code. El plan Max permite uso headless.
- Cuenta Cloudflare con dominio gestionado (solo si va a usar el DdnsWorker).
Asumimos su dominio se llama midominio.cl, el asistente es asistente@midominio.cl,
y el jefe es usted@midominio.cl. Sustituya con sus valores reales.
Crear una app registration nueva en Entra (Azure AD → App registrations → New).
Permisos delegados a otorgar (y dar admin consent desde Entra):
Chat.ReadWriteChat.ReadWrite.All(requerido para subscriptions a/me/chats/getAllMessages)Mail.ReadWriteMail.SendCalendars.ReadWriteUser.Readoffline_access
Anote el Application (client) ID y el Tenant ID.
Desde una PowerShell en cualquier máquina con browser:
$tenantId = '<tu-tenant-id>'
$clientId = '<application-id-de-la-app-arriba>'
$scopes = 'openid profile offline_access User.Read Mail.ReadWrite Mail.Send Chat.ReadWrite Chat.ReadWrite.All Calendars.ReadWrite'
# Device code flow
$dc = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" `
-Body @{ client_id=$clientId; scope=$scopes }
"Abra: $($dc.verification_uri)"
"Code: $($dc.user_code)"
# Esperar a que el usuario firme y luego:
Start-Sleep -Seconds 30
$tk = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body @{
grant_type = 'urn:ietf:params:oauth:grant-type:device_code'
client_id = $clientId
device_code = $dc.device_code
}
# Guardar el refresh_token cifrado con DPAPI
$rt = $tk.refresh_token
$cred = New-Object PSCredential('asistente@midominio.cl', (ConvertTo-SecureString $rt -AsPlainText -Force))
$cred | Export-Clixml -Path 'C:\secrets\asistente-refresh.cred.xml'Importante: Microsoft rota el refresh_token en cada uso. El handler maneja la
rotación y reescribe el XML automáticamente. Si dos procesos consumen el mismo XML al
mismo tiempo, se desincronizan — designe un único consumidor por XML.
Las subscriptions con includeResourceData=true requieren un certificado RSA-2048 que
Microsoft usa para cifrar el contenido de cada notificación. Generar uno self-signed:
$cert = New-SelfSignedCertificate `
-Subject 'CN=phipes-webhook-encryption' `
-KeyAlgorithm RSA -KeyLength 2048 `
-KeyUsage KeyEncipherment, DataEncipherment `
-NotAfter (Get-Date).AddYears(2) `
-CertStoreLocation 'Cert:\CurrentUser\My'
# Exportar PFX con password
$pwd = ConvertTo-SecureString '<password-aleatorio>' -AsPlainText -Force
Export-PfxCertificate -Cert $cert -FilePath 'C:\secrets\webhook-encryption.pfx' -Password $pwd
# El "encryption certificate" que se manda a Microsoft es el DER del public key, base64
$pubDer = $cert.GetRawCertData()
$b64 = [Convert]::ToBase64String($pubDer)
"$b64" # → este string va al campo encryptionCertificate al crear la subscriptionVía Exchange Online PowerShell, una sola vez:
Connect-ExchangeOnline
Add-MailboxPermission `
-Identity 'usted@midominio.cl' `
-User 'asistente@midominio.cl' `
-AccessRights FullAccess `
-InheritanceType All `
-AutoMapping $true
Add-MailboxFolderPermission `
-Identity 'usted@midominio.cl:\Calendario' `
-User 'asistente@midominio.cl' `
-AccessRights Editor `
-SharingPermissionFlags DelegateSi su calendario está en inglés use \Calendar.
Como administrador SQL:
CREATE DATABASE PhipesAssistant;
GO
USE PhipesAssistant;
GO
CREATE TABLE WebhookProcessedMessages (
SubscriptionId NVARCHAR(64) NOT NULL,
ResourceId NVARCHAR(512) NOT NULL,
ProcessedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
Channel NVARCHAR(16) NOT NULL,
CONSTRAINT PK_WebhookProcessedMessages PRIMARY KEY CLUSTERED (SubscriptionId, ResourceId)
);
CREATE INDEX IX_WebhookProcessedMessages_ProcessedAt ON WebhookProcessedMessages(ProcessedAt);
-- Login Windows para la cuenta del app pool de IIS
CREATE LOGIN [DOMINIO\svc-cuenta-app-pool] FROM WINDOWS;
USE PhipesAssistant;
CREATE USER [DOMINIO\svc-cuenta-app-pool] FOR LOGIN [DOMINIO\svc-cuenta-app-pool];
ALTER ROLE db_datareader ADD MEMBER [DOMINIO\svc-cuenta-app-pool];
ALTER ROLE db_datawriter ADD MEMBER [DOMINIO\svc-cuenta-app-pool];Descargar claude.exe (Windows binary) y ponerlo en C:\Tools\Claude\.
# Como el usuario humano con suscripción Claude, una sola vez:
claude setup-token
# Genera un token long-lived (1 año) que aparece una sola vez en consola.
# Guárdelo cifrado con DPAPI:
$token = '<el-token-sk-ant-oat01-...>'
$cred = New-Object PSCredential('claude-code', (ConvertTo-SecureString $token -AsPlainText -Force))
$cred | Export-Clixml -Path 'C:\secrets\claude-oauth.cred.xml'Crear un perfil dedicado para el asistente en cualquier path (ej.
C:\AsistenteProfile\) y dentro el subdirectorio .claude/ con:
CLAUDE.md— system prompt global del asistente (identidad, reglas, tono).skills/— copiar losSKILL.mdde cada skill desde el repo o crearlos.settings.jsoncon los permisos pre-aprobados (modo headless no puede mostrar prompts interactivos):
{
"permissions": {
"allow": [
"WebFetch",
"WebSearch",
"PowerShell(*)",
"Read(**)",
"Glob(**)",
"Grep(**)",
"Write(**)",
"Edit(**)"
]
}
}En Windows el tool de shell se llama
PowerShell, NOBash. Esto es un detalle esencial —Bash(*)no matchea nada en Windows.
Import-Module WebAdministration
# Crear app pool con identity de service account dedicada
$apName = 'phipes.assistant'
New-WebAppPool -Name $apName
Set-ItemProperty "IIS:\AppPools\$apName" -Name processModel -Value @{
identityType = 'SpecificUser'
userName = 'DOMINIO\svc-cuenta-app-pool'
password = '<password-del-svc>'
}
# Always-on (sin esto, el BackgroundService muere por idle)
& "$env:windir\system32\inetsrv\appcmd.exe" set apppool $apName /startMode:AlwaysRunning
& "$env:windir\system32\inetsrv\appcmd.exe" set apppool $apName /processModel.idleTimeout:00:00:00
& "$env:windir\system32\inetsrv\appcmd.exe" set apppool $apName /recycling.periodicRestart.time:00:00:00
# Crear el site (asume binding a webhook.midominio.cl con cert TLS)
New-Website -Name 'phipes.assistant' `
-PhysicalPath 'C:\inetpub\phipes.assistant' `
-ApplicationPool $apName `
-HostHeader 'webhook.midominio.cl' `
-Port 443 -Ssl
& "$env:windir\system32\inetsrv\appcmd.exe" set site 'phipes.assistant' /serverAutoStart:true
# Variables de entorno del app pool (paths de logs y sessions)
Add-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' `
-Filter "system.applicationHost/applicationPools/add[@name='$apName']/environmentVariables" `
-Name "." -Value @{ name='HANDLER_LOG_DIR'; value='C:\inetpub\phipes.assistant\logs' }
Add-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' `
-Filter "system.applicationHost/applicationPools/add[@name='$apName']/environmentVariables" `
-Name "." -Value @{ name='HANDLER_SESSIONS_DIR'; value='C:\inetpub\phipes.assistant\sessions' }ACL sobre C:\inetpub\phipes.assistant\: DOMINIO\svc-cuenta-app-pool con Modify.
Tras dotnet user-secrets set desde src/Phipes.Assistant.WebhookHandler/ (corriendo
como la cuenta del app pool, no como el dev — porque ese perfil es donde IIS busca los
secrets en producción):
# Webhook (estado compartido entre creator de subscriptions y handler)
dotnet user-secrets set "Webhook:ClientState" "<random-string-largo-unico>"
# Encryption (PFX + password + identificador)
dotnet user-secrets set "Encryption:PfxPath" "C:\secrets\webhook-encryption.pfx"
dotnet user-secrets set "Encryption:PfxPassword" "<password-del-pfx>"
dotnet user-secrets set "Encryption:CertificateId" "phipes-webhook-202605"
# Graph (token delegated, identidad de la asistente)
dotnet user-secrets set "Graph:TenantId" "<tenant-id>"
dotnet user-secrets set "Graph:ClientId" "<app-id-pública-microsoft-graph-powershell>"
dotnet user-secrets set "Graph:RefreshTokenPath" "C:\secrets\asistente-refresh.cred.xml"
dotnet user-secrets set "Graph:UserPrincipalName" "asistente@midominio.cl"
dotnet user-secrets set "Graph:Scopes" "openid offline_access User.Read Mail.ReadWrite Mail.Send Chat.ReadWrite Calendars.ReadWrite"
# WebhookApp (token con admin-consent para administrar subscriptions)
dotnet user-secrets set "WebhookApp:TenantId" "<tenant-id>"
dotnet user-secrets set "WebhookApp:ClientId" "<application-id-de-tu-app-registration>"
dotnet user-secrets set "WebhookApp:RefreshTokenPath" "C:\secrets\asistente-refresh-webhookapp.cred.xml"
dotnet user-secrets set "WebhookApp:UserPrincipalName" "asistente@midominio.cl"
# Renewer (auto-renovación de subscriptions)
dotnet user-secrets set "Renewer:IntervalMinutes" "30"
dotnet user-secrets set "Renewer:RenewWhenLessThanMinutes" "30"
dotnet user-secrets set "Renewer:ChatExtendMinutes" "55"
dotnet user-secrets set "Renewer:MailExtendMinutes" "4230"
dotnet user-secrets set "Renewer:SubscriptionIds:0" "<id-de-tu-sub-chat>"
dotnet user-secrets set "Renewer:SubscriptionIds:1" "<id-de-tu-sub-mail>"
# Idempotency (MSSQL)
dotnet user-secrets set "Idempotency:ConnectionString" "Server=.;Database=PhipesAssistant;Integrated Security=True;Encrypt=False;TrustServerCertificate=True"
# Claude Code
dotnet user-secrets set "Claude:ExePath" "C:\Tools\Claude\claude.exe"
dotnet user-secrets set "Claude:OAuthTokenPath" "C:\secrets\claude-oauth.cred.xml"
dotnet user-secrets set "Claude:ClaudeHome" "C:\AsistenteProfile"
dotnet user-secrets set "Claude:MaxBudgetUsd" "0.50"
dotnet user-secrets set "Claude:TimeoutSeconds" "300"
dotnet user-secrets set "Claude:AppendSystemPrompt" "<prompt-extra-con-lista-de-skills>"
# Security (validación JWT)
dotnet user-secrets set "Security:RejectInvalidJwts" "false" # true en producción tras validaciónUna vez la URL pública del webhook (https://webhook.midominio.cl/webhook/teams) esté
arriba y respondiendo 200, crear las subscriptions:
# Refrescar access token con la app que tiene Chat.ReadWrite.All admin-consent
$rt = (Import-Clixml 'C:\secrets\asistente-refresh-webhookapp.cred.xml').GetNetworkCredential().Password
$resp = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" `
-Body @{
grant_type='refresh_token'
client_id='<app-id-de-tu-webhookapp>'
refresh_token=$rt
scope='openid offline_access User.Read Chat.ReadWrite Chat.ReadWrite.All'
} -ContentType 'application/x-www-form-urlencoded'
# Persistir el refresh rotado
if ($resp.refresh_token -and $resp.refresh_token -ne $rt) {
(New-Object PSCredential('asistente@midominio.cl', (ConvertTo-SecureString $resp.refresh_token -AsPlainText -Force))) |
Export-Clixml -Path 'C:\secrets\asistente-refresh-webhookapp.cred.xml'
}
$hdr = @{ Authorization = "Bearer $($resp.access_token)" }
# Encryption certificate (public key DER base64)
$encB64 = '<base64-del-public-key-de-tu-cert>'
$encId = 'phipes-webhook-202605'
# Subscription Teams chat (max 60 min, renovar con SubscriptionRenewer)
$body = @{
changeType = 'created'
notificationUrl = 'https://webhook.midominio.cl/webhook/teams'
lifecycleNotificationUrl = 'https://webhook.midominio.cl/webhook/teams/lifecycle'
resource = '/me/chats/getAllMessages'
expirationDateTime = (Get-Date).ToUniversalTime().AddMinutes(55).ToString('o')
clientState = '<el-clientState-que-pusiste-en-User-Secrets>'
includeResourceData = $true
latestSupportedTlsVersion = 'v1_2'
encryptionCertificate = $encB64
encryptionCertificateId = $encId
} | ConvertTo-Json -Depth 10 -Compress
Invoke-RestMethod -Method POST `
-Uri 'https://graph.microsoft.com/v1.0/subscriptions' `
-Headers $hdr -Body $body -ContentType 'application/json'
# Subscription Mail (max 4230 min = 70h, también renovable)
# Idem al anterior pero con resource = '/me/messages?$select=subject,from,toRecipients,bodyPreview,receivedDateTime,isRead'Anote los id retornados y agréguelos a Renewer:SubscriptionIds.
dotnet build src/Phipes.Assistant.WebhookHandler -c Release
dotnet publish src/Phipes.Assistant.WebhookHandler -c Release -o C:\inetpub\phipes.assistant
# Reciclar el app pool
Restart-WebAppPool 'phipes.assistant'Validación: curl https://webhook.midominio.cl/health debe responder JSON 200.
C:\inetpub\phipes.assistant\ ← IIS site root, código del WebhookHandler
├── Phipes.Assistant.WebhookHandler.dll
├── … (deps)
├── runtimes\win-x64\native\ ← DLLs nativas de Microsoft.Data.SqlClient
├── logs\ ← archivos de log diarios (HANDLER_LOG_DIR)
└── sessions\ ← flag files de continuidad por chat (HANDLER_SESSIONS_DIR)
C:\AsistenteProfile\ ← profile del asistente (USERPROFILE forzado)
└── .claude\
├── CLAUDE.md ← identidad y reglas globales del asistente
├── memory\ ← memorias persistentes (markdown)
├── skills\ ← skills (markdown) descubiertos automáticamente
├── settings.json ← permisos del modo headless
├── .credentials.json ← OAuth de Claude Code (auto-gestionado)
└── sessions\, projects\, … ← state interno
C:\secrets\ ← tokens cifrados con DPAPI
├── asistente-refresh.cred.xml
├── asistente-refresh-webhookapp.cred.xml
├── claude-oauth.cred.xml
├── webhook-encryption.pfx
├── cloudflare-api.token.xml (DdnsWorker)
└── unifi-api.token.xml (DdnsWorker)
Cosas no obvias que cuestan al implementar:
-
Bash(*)no funciona en Windows. El tool de shell que Claude Code expone se llamaPowerShellen Windows. Allowlist debe serPowerShell(*), noBash(...). -
Microsoft rota refresh tokens en cada uso. Si dos procesos comparten el mismo
*.cred.xml, se desincronizan y uno empieza a fallar coninvalid_grant. Designe un único consumidor por XML. -
includeResourceData=truerequiere$selecten mail subscriptions. Sin$selectMicrosoft devuelve400: select clause should be present in query string to support rich notifications. -
El
iddel recurso NO viene en el JSON descifrado cuando se usa$selectsin incluiridexplícito. Hay que extraerlo delnotification.ResourceURL con regex:messages\(['"]([^'"]+)['"]\). -
Cada app context ve solo sus propias subscriptions. Una "public client app" de Microsoft no puede listar/PATCH subscriptions creadas por tu app registration. El
SubscriptionRenewerdebe usar el token de la misma app que creó las subscriptions. -
Microsoft entra en "silencio temporal" a veces — deja de entregar notifications aunque la subscription siga activa. El fix es hacer PATCH a
expirationDateTimepara "tocar" la sub; eso reactiva el delivery. ElSubscriptionReneweraquí hace PATCH preventivo cada 30 min para prevenir el caso. -
ASP.NET Core in-process hosting NO flushea
ILoggeral stdout log de IIS. Los logs se pierden si no se configura un sink alternativo. La claseFileLoggeren este repo escribe a un archivo diario que sobrevive a reinicios del pool. -
IIS in-process hosting requiere always-on (
startMode=AlwaysRunning,idleTimeout=0,serverAutoStart=true) para que elBackgroundServiceno muera por idle del app pool. -
PFX cargado con
MachineKeySet | PersistKeySetvive en el HKLM, accesible para la identidad del app pool. Sin esos flags el cert solo está disponible para el user que lo cargó. -
Send-Asno es necesario (y no se recomienda). Cuando el asistente responde correos del jefe envía desde su propia identidad con CC al jefe. Cuando agenda reuniones las crea en su propio calendario invitando al jefe. Esto da trazabilidad clara de quién hizo qué. -
Cuando
Get-Content -Waitse usa para tailar un log, validar fecha UTC. El archivo rota por fecha UTC (no local) — un tail local que no recalcula la fecha UTC se queda atado al archivo de "ayer" después de medianoche UTC.
- Tokens nunca en texto plano. Todos cifrados con DPAPI (scope
CurrentUser). - Refresh tokens dedicados por consumidor. No se comparten XMLs entre procesos.
clientStatees un secreto compartido entre el creador de la subscription y el handler. Debe ser un string aleatorio largo (>32 chars). Vive en User Secrets, nunca en código.- Validación JWT de
validationTokens[]contra JWKS de Microsoft, previene que un atacante que descubra la URL del webhook inyecte notificaciones falsas (modoshadowpor default, activable arejectdesdeSecurity:RejectInvalidJwts). - HMAC-SHA256 sobre el ciphertext de cada notification, verificado antes de descifrar — previene tampering.
- Idempotencia transaccional con PK clustered en MSSQL, descarta duplicados de Microsoft sin re-procesar.
- Scopes mínimos.
Chat.ReadWrite.Allsolo en la app dedicada a manejar subscriptions; resto de operaciones usanChat.ReadWritedelegated.
| Síntoma | Diagnóstico |
|---|---|
Webhook recibe POST 202 pero no responde nada |
Verificar <HANDLER_LOG_DIR>\handler-yyyymmdd.log — el procesamiento es async (Task.Run). |
Descartadas N notifications por clientState invalido |
El Webhook:ClientState en User Secrets no matchea el de la subscription. Recrear sub. |
invalid_grant al refrescar token |
Refresh token rotó en otro proceso. Re-provisionar XML con device code flow. |
Subscription validation request failed. HTTP 'NotFound' |
Site IIS está Stopped (no el app pool). Verificar con Get-Website. |
Notification endpoint must respond with 200 OK |
Misma causa que arriba; o el cert TLS no es válido externamente. |
MSDTC has cancelled o errores raros de MSSQL |
Faltan DLLs nativas de SqlClient — copiar recursivamente publish/runtimes/win-x64/native/. |
claude.exe Error: Input must be provided |
Modo headless detectó que no hay TTY. No usar --remote-control en scheduled task. |
| Sarah dice "no tengo acceso al mailbox" | El --append-system-prompt no mencionaba skills, o el settings.json no permite PowerShell. |
Suite xUnit en tests/Phipes.Assistant.WebhookHandler.Tests/. Cubre los componentes
sensibles a seguridad:
NotificationDecrypterTests— genera un cert RSA-2048 self-signed en memoria, construye payloads cifrados siguiendo el spec de Microsoft Graph (AES-256-CBC + HMAC-SHA256 + RSA-OAEP-SHA1) y verifica roundtrip, detección de tampering vía HMAC, rechazo de thumbprint incorrecto y payload grande (>10 KB).JwtNotificationValidatorTests— inyecta unHttpMessageHandlerstub que devuelve un JWKS controlado para validar: tokens vacíos, no parseables, sinkid/tid, contiddistinto al esperado, con firma de otra key, conaudienceincorrecto, vencidos, y un caso de happy path con firma válida.
Para ejecutarlos localmente:
dotnet test Phipes.Assistant.sln --configuration ReleaseLos tests corren automáticamente en GitHub Actions (.github/workflows/ci.yml) en cada
push/PR a main sobre windows-latest (el NotificationDecrypter usa
X509KeyStorageFlags.MachineKeySet, comportamiento solo soportado en Windows).
MIT. Vea LICENSE. Manteniendo el aviso de copyright, úselo, modifíquelo, comercialícelo,
o adáptelo a su entorno.