Skip to main content

APIs, webhooks & tools

In one line: everything the system calls out to (Business Central, AgentMail, the optional finance API, the CDN), everything that calls in (11 webhooks), and every action the LLM can take (the tool catalog) — plus the preprocessor/postprocessor pipeline that wraps each turn.

What it is

HIGHFIELD talks to the outside world over three lanes:

  • Outbound to Business Central (BC) — its system of record. Two BC API surfaces: the standard v2.0 API and a custom per-tenant extension API (propMaint). All BC calls authenticate with an OAuth2 client-credentials token from Microsoft Entra (Azure AD).
  • Outbound email — the production lane via Lua's send-message API, plus a separate AgentMail client; an optional finance API fires only if its env vars are set. See Communication & email.
  • Inbound webhooks — 11 endpoints registered at src/index.ts:552-564, reachable at https://webhook.heylua.ai/{agentId}/{name}. The most important is inbound-email. Most others are unauthenticated internal/ops tools; only approval-decision is HMAC-protected.

A platform constraint worth flagging: Lua webhooks are POST-only, so the old browser-clickable "Approve/Reject" magic links (a GET) always 404'd — approvals now flow through email replies on inbound-email.

Integration overview

  • BC standard API. Base https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0 (bc-client.ts:18-22). Reads customers (tenants), vendors, property dimension values; creates/posts purchase orders, purchase invoices, sales invoices, GL journal lines. Tickets are NOT BC projects — they're JSON documentAttachments (ticket-{id}.json, audit-{id}.json) on the tenant's customers record (bc-entities.ts:494-496,638-645).
  • BC custom extension ("propMaint"). Base .../api/propMaint/v1/v1.0/companies (bc-custom-api.ts:20-24). A dual-write, fire-and-forget mirror of the attachment source-of-truth; failures logged, never propagated. Exposes a custom postPurchaseOrder action the standard API can't do.
  • Microsoft Entra ID (OAuth2). Token issuer for all BC calls; client-credentials against login.microsoftonline.com/{tenantId}/oauth2/v2.0/token, scope …/.default (bc-auth.ts:10-11). Cached, refreshed 5 min before expiry.
  • Lua send-message API. POST https://api.heylua.ai/developer/agents/{agentId}/send-message, Bearer LUA_API_KEY. The production outbound-email mechanism.
  • AgentMail. https://api.agentmail.to/v0, Bearer AGENTMAIL_API_KEY. Dedicated mailbox; every to forcibly rewritten to AGENTMAIL_OVERRIDE_TO (default stefan@heylua.ai).
  • Finance API (optional / likely dormant). Tools POST {FINANCE_API_URL}/approvals and /payments only if both env vars set. ⚠️ Unverified whether configured anywhere.
  • Lua CDN. https://cdn.heylua.ai/{fileId}.{ext}; images re-hosted here (BC caps image-URL at 500 chars, so raw S3 URLs are rejected).

Reference — outbound endpoints

Auth: OAuth2 = Bearer from Entra; LuaKey = LUA_API_KEY; AMKey = AGENTMAIL_API_KEY; FinKey = FINANCE_API_KEY.

SystemOperationMethodPathAuthSource
Entra OAuth2Acquire tokenPOSTlogin.microsoftonline.com/{tenantId}/oauth2/v2.0/tokenid+secretbc-auth.ts:10-11,40-54
BC standardList companiesGET/companiesOAuth2bc-client.ts:100
BC standardList entitiesGET/companies({id})/{entity}{?$filter,$top}OAuth2bc-client.ts:118; bc-entities.ts:121..
BC standardGet entity by idGET/companies({id})/{entity}({entityId})OAuth2bc-client.ts:133
BC standardGet nested entityGET/companies({id})/{parent}({pid})/{child}OAuth2bc-client.ts:150
BC standardCreate entityPOST/companies({id})/{entity}OAuth2bc-client.ts:166
BC standardCreate nested entityPOST/companies({id})/{parent}({pid})/{child}OAuth2bc-client.ts:182
BC standardUpdate entityPATCH/companies({id})/{entity}({entityId}) (needs If-Match)OAuth2bc-client.ts:201
BC standardDelete entityDELETE/companies({id})/{entity}({entityId})OAuth2bc-client.ts:217
BC standardBound action: receive+invoice POPOST…/purchaseOrders({poId})/Microsoft.NAV.receiveAndInvoiceOAuth2bc-client.ts:231; bc-entities.ts:1060
BC standardUpload attachment (metadata)POST…/documentAttachmentsOAuth2bc-client.ts:251
BC standardUpload attachment (content)PATCH…/documentAttachments({attId})/attachmentContent (If-Match)OAuth2bc-client.ts:262-273
BC standardRead attachment content (the ticket-JSON store)GET…/documentAttachments({attId})/attachmentContentOAuth2bc-client.ts:287
BC customUpsert ticket/activity/image/quote/approval/message/escalationPOST/PATCH…/propMaint/v1/v1.0/companies({id})/{table}OAuth2bc-custom-api.ts:85,95,181-768
BC customCustom action: post purchase orderPOST…/companies({id})/postPurchaseOrderOAuth2bc-custom-api.ts:443-458
BC customRead views/setupGET…/companies({id})/{view}{?$filter}OAuth2bc-custom-api.ts:113-122,572-693
Lua send-messageSend emailPOSTapi.heylua.ai/developer/agents/{agentId}/send-messageLuaKeyemail-notifications.ts:150-157
AgentMailEnsure/create inboxPOSTapi.agentmail.to/v0/inboxesAMKeyagentmail.ts:68-74
AgentMailSend messagePOST/inboxes/{inboxId}/messages/send (to forced)AMKeyagentmail.ts:110-113
AgentMailReply to threadPOST/inboxes/{inboxId}/messages/{messageId}/replyAMKeyagentmail.ts:139
AgentMailGet attachment infoGET/inboxes/{inboxId}/messages/{messageId}/attachments/{attachmentId}AMKeyagentmail.ts:160-162
AgentMailRegister webhookPOST/webhooks (message.received)AMKeyagentmail.ts:180-187
Lua CDNDownload / re-host imageGET/uploadcdn.heylua.ai/{fileId}.{ext} via CDN.upload()platforminbound-email.webhook.ts:251
Finance API (opt)Submit approvalPOST{FINANCE_API_URL}/approvalsFinKeySendForApprovalTool.ts:180-187
Finance API (opt)Initiate paymentPOST{FINANCE_API_URL}/paymentsFinKeyInitiatePaymentTool.ts:124-131

Environment switching is purely the BC_ENVIRONMENT env var (default 'Production') interpolated into the BC URL — no code-level prod/sandbox branch. BC_TENANT_ID/BC_CLIENT_ID/BC_CLIENT_SECRET default to hardcoded values if unset (bc-auth.ts:21-24). See Security and Configuration.

Reference — inbound webhooks

All registered in src/index.ts:552-564. Route = https://webhook.heylua.ai/{agentId}/{name}. Unless noted, all return HTTP 200 with a JSON body even on validation failure (errors are in the body, not the status).

NameMethodAuthKey payloadSide effectsSource
inbound-emailPOSTAgentMail event shape only (message.received) — no signaturemessage: message_id, from, subject, text, html, attachments[], headers, in_reply_to, references, thread_idLoop/rate guard; thread-id derivation; approver-reply interception; image→CDN (HEIC→JPEG); resolve sender; auto-create ticket OR dispatch vendor tool; hallucinated-ID stripping; sends reply; dual logCommunicationinbound-email.webhook.ts:37-1099
approval-decisionGET (query.token)HMAC-SHA256 token (APPROVAL_TOKEN_SECRET, 7-day TTL, constant-time)token (encodes ticketId, companyId, decision, expiresAt)applyFinanceDecision → status transition + notify. Method mismatch: browser GET vs Lua POST → legacy links 404'dapproval-decision.webhook.ts:21-95
finance-approvalPOSTNonecompanyId, ticketId, approved, approverName, … (ticketId /^MT-\d{4}-\d{3,}$/)applyFinanceDecision → status + approval fields, downstream PO/invoice + notificationsfinance-approval.webhook.ts:27-84
escalation-responsePOSTNonecompanyId, ticketId, action, resolverName, …reassign vendor / override status / resolve escalation / notify; in-app only (no email)escalation-response.webhook.ts:28-191
open-ticketsPOST or GETNonecompanyIdRead-only: list tickets (excl. closed/rejected/cancelled), CDN-format images, sortopen-tickets.webhook.ts:18-146
clear-dataPOSTSafety flag only (confirm:true)confirm, companyId?Destructive: deletes agent-test tickets (MT-…) + audit attachmentsclear-data.webhook.ts:21-98
seed-dataPOSTNonecompanyId?, createDemoProjects?Verifies BC reads; optionally creates 2 demo ticketsseed-data.webhook.ts:21-253
create-vendorPOSTNonecompanyId, vendorId, specialties[], …Writes vendor-data.json extended-data on the BC vendorvendor-management.webhook.ts:28-110
list-vendorsPOST or GETNonecompanyId, filtersRead-only: list vendors + extended datavendor-management.webhook.ts:126-264
assign-vendorPOSTNonecompanyId, ticketId, vendorId, …Validates ticket reported + specialty/area; sets → vendor_contactedvendor-management.webhook.ts:297-493
vendor-responsePOSTNonecompanyId, ticketId, vendorId, type, quote?/completion?/…quote → pending_approval + creates BC PO; work_completed → completed + conditional invoice post (BC_AUTO_POST_INVOICE); tenant notificationsvendor-response.webhook.ts:42-457

⚠️ Security: 9 of 11 webhooks have no auth — anyone with the public URL can POST. Most dangerous: clear-data, finance-approval, escalation-response, assign-vendor. See Security.

Reference — tool catalog

Tools register through three skills (src/index.ts:549). Names below are the literal strings the LLM calls.

Intake (tenant-facing) — property-maintenance skill

ToolPurposeSide effectsGuardsSource
get_user_contextIdentify caller from phone/email; cache BC identitypersists bcIdentity; no BC write/emailtest-override only when BC_ENVIRONMENT≠Production; blocks unregisteredGetUserContextTool.ts:13
create_maintenance_ticketCreate one ticket (delegates to auto-assign service)BC create → REPORTED; vendor+tenant emails; seeds email_msgid_ticketoverrides agent IDs from bcIdentity; tenant-identity duplicate checkCreateMaintenanceTicketTool.ts:21
my_ticketsTenant views/updates/cancels their own ticketsupdate→BC; cancel→CANCELLED; auditowner gate on every action; image cap 10MyTicketsTool.ts:18
search_maintenance_historyNL search of history + patternsread-only⚠️ no owner scope, no role gateSearchMaintenanceHistoryTool.ts:12
update_ticket_detailsPatch ticket fields (no status, no images)BC patch; auditID/empty/existence; ⚠️ no owner checkUpdateTicketDetailsTool.ts:13
upload_issue_imagesURLs → CDN → attach to ticket + notify vendorCDN; BC patch + syncImages; vendor emailtype allow-list; cap 10; ⚠️ no owner checkUploadIssueImagesTool.ts:15

Vendor self-service — vendor-management skill

ToolPurposeSide effectsGuardsSource
list_available_jobsList reported, unassigned matching jobsread-onlyvendor existence; specialty matchListAvailableJobsTool.ts:10
claim_jobVendor self-assigns → vendor_contactedBC patch; auditactive-job-limit (maxVendorActiveJobs, def 5); status reportedClaimJobTool.ts:13
decline_jobDecline → resets to reported, escalatesclears assignment; BC escalation; admin+tenant noticeresolveVendorId; status vendor_contacted/quotedDeclineJobTool.ts:16
my_assigned_jobsList vendor's own jobsread-onlyimplicit via vendorNumberMyAssignedJobsTool.ts:9
submit_quoteCost + earliest time; resolves cost gateBC → pending_tenant_confirmation/pending_approval; emails; no PO (deferred to lock-in)owner; status gate; cost-threshold gateSubmitQuoteTool.ts:23
submit_revised_quoteRevised scope/costarchives prior; cancels+recreates POauto-approve only if ≤ prior AND ≥20% of originalSubmitRevisedQuoteTool.ts:21
offer_visit_slotsThe only tool that sends the tenant a visit menuBC (offeredSlots); tenant chat+emailfeature-flag; owner; status gate; anti-fabrication of datesOfferVisitSlotsTool.ts:29
start_workin_progress / resume from on_holdBC patch; audit; tenant noticeowner; status approved/on_holdStartWorkTool.ts:15
pause_workon_holdBC patch; audit; tenant noticeowner; status in_progressPauseWorkTool.ts:15
complete_jobComplete with photos + invoice → completedstatus; BC PO receive/invoice gated by BC_AUTO_POST_INVOICE; emailsphotos ≥ minCompletionPhotos (def 1); owner; status in_progressCompleteJobTool.ts:17
reschedule_visitVendor sets/moves their visit dateBC (TZ-anchored ISO); conditional lock-in; tenant noticeowner; finished-status block; TZ re-anchorRescheduleVisitTool.ts:26
validate_invoiceCross-check invoice vs ticket — verdict onlyread-onlyowner; status; >10% over = blockingValidateInvoiceTool.ts:16
upload_vendor_photosVendor photo URLs → CDNCDN; for completion: BC patch + syncImagestype allow-list; ⚠️ no owner checkUploadVendorPhotosTool.ts:14

Agent-side vendor + escalation — property-maintenance skill

ToolPurposeSide effectsSource
lookup_vendorsRank active vendors for an issueread-onlyLookupVendorsTool.ts:10
send_vendor_requestAgent assigns a vendor → vendor_contactedBC patch; audit. ⚠️ no email in source despite copySendVendorRequestTool.ts:13
record_vendor_quoteAgent records a quote → pending_approval + BC POBC patch; creates BC PO; auditRecordVendorQuoteTool.ts:13
update_vendor_metricsRecompute vendor performanceBC vendor extended data; auditUpdateVendorMetricsTool.ts:12
relay_tenant_rescheduleRelay tenant's time request to vendorBC (tenantRescheduleRequest); vendor email; system-cancel at 3rd pushRelayTenantRescheduleTool.ts:35
escalate_ticketBC escalation + email adminsBC escalation (OPEN); escalatedAt; admin emailsEscalateTicketTool.ts:26

Approval — property-maintenance skill

ToolPurposeSide effectsSource
check_approval_thresholdIs this quote auto-approvable?read-only (⚠️ historical spend hardcoded 0)CheckApprovalThresholdTool.ts:13
send_for_approvalAuto-approve (≤ threshold) or route to financeauto → APPROVED + tenant chat; finance → PENDING_APPROVAL + approver emailsSendForApprovalTool.ts:22
initiate_paymentAuthorize payment for completed workoptional finance POST; BC payment; auditInitiatePaymentTool.ts:17

Approvals are NOT a tool. Cost approve/reject is recorded automatically when the authorized approver replies YES/NO to the approval email (src/index.ts:517-524). The agent has no approve/reject tool.

Completion — property-maintenance skill

ToolPurposeSide effectsSource
confirm_visit_timeTenant confirms time → lock-in (if cost approved)BC; vendor notify; lockInTicket or tenantTimeConfirmedConfirmVisitTimeTool.ts:20
record_tenant_feedbackCapture feedback (no status change)BC feedback fields; audit; nextStepRecordTenantFeedbackTool.ts:19
close_ticketClose completed ticket after checksBC → CLOSED; optional BC Sales Invoice; tenant chatCloseTicketTool.ts:18
record_completion_docsRecord vendor photos+invoice → completedCDN; BC → COMPLETED; auditRecordCompletionDocsTool.ts:18
record_tenant_disputeDispute completed work → disputed + escalateBC → DISPUTED; escalation; admin emailsRecordTenantDisputeTool.ts:15
request_tenant_confirmationAsk tenant to confirm satisfactionBC; tenant chat; creates one-time reminder JobRequestTenantConfirmationTool.ts:18
notify_tenant_accessTell tenant contractor needs accesstenant chat + email; no field writeNotifyTenantAccessTool.ts:12

Admin (approver-only) — admin skill

All four self-guard with if (!user?.isAdmin) → "admin-only".

ToolPurposeSource
admin_query_tickets"How many / who / which" — code-computed aggregates + optional listAdminQueryTool.ts:18
admin_resolve_escalationRelay admin's instruction to tenant/vendor; resolve escalation (not finance)AdminResolveEscalationTool.ts:21
admin_get_ticketFull detail of one ticket (incl. last 10 events)GetTicketTool.ts:13
list_pending_approvalsTickets currently pending_approvalListPendingApprovalsTool.ts:13

The processing pipeline

Preprocessors (by priority, lower runs first)

PriorityNameWhat it doesChannel
5auto-reply-guardBlocks automated senders + rate-limited senders (mail-loop prevention)all
10emergency-triageLife-threatening keywords → hardcoded safety template; else prepends [SYSTEM: URGENT]; injects open-ticket count + property blockall
15admin-modeIf sender ∈ APPROVER_EMAIL/BC approval emails, persists user.isAdminall
20user-contextAdmins bypass; cache-hit fast path; blocks unregistered; persists bcIdentityall
21identity-injectionPrepends [SYSTEM CONTEXT — DO NOT REPEAT] identity blockall (skips admins)
22current-thread-ticket-injection4-tier ownership-gated resolver of the thread's ticket; [CURRENT THREAD HINT]; seeds email_msgid_ticket; blocks cross-tenant bindsemail-only (skips admins)
23role-content-guardStrips synthetic blocks; hard-refuses role-mismatched contentall
25vendor-action-extractorDeterministic dispatcher: runs the vendor BC-write tool directly (bypassing the LLM), then injects [AUTHORITATIVE OUTCOME]email-only
30email-channel-handlerRe-uploads non-CDN image URLs to CDN; drops video/audio; extracts inline base64; loads [PRIOR THREAD] historyemail-only

Postprocessors (execution = array order)

OrderNameWhat it does
1collapse-doubled-narrationStrips the model's pre-tool intent narration glued in front of the real reply
2tool-call-validatorAnti-hallucination net: verifies cited MT- IDs against BC + ownership; rewrites to a safe fallback on a hallucinated claim
3ticket-summaryStamps a property+status card (non-email only; cross-tenant gate)
4communication-logAudit writer: strips synthetic blocks, writes inbound+outbound to BC; never alters the response
5suppress-on-ticket-creationThe one full-blank stage: returns '' when justCreatedTicket/justConfirmedVisit set
6sanitize-admin-outputAdmin turns only: strips leaked reasoning
7admin-reply-formatAdmin turns only: renders Markdown reply to email-safe HTML
sanitize-vendor-outputINACTIVE — imported but not in the array (dropped in a bisect)

Skills/persona behavioral rules (money-blindness, urgency, one-ticket-per-email, two-gate, photo policy) are summarized in Core concepts and authored at src/index.ts:71-546.

Gotchas & failure modes

  • Inconsistent ownership enforcement. Owner gates exist on my_tickets, confirm_visit_time, record_tenant_feedback, but not on search_maintenance_history, update_ticket_details, upload_issue_images, close_ticket, record_completion_docs, record_tenant_dispute, request_tenant_confirmation, notify_tenant_access, upload_vendor_photos, update_vendor_metrics. They rely on the role-content-guard + thread-ownership gates instead. See Security.
  • The LLM is bypassed for vendor email side-effects. vendor-action-extractor calls the BC-write tool itself, then injects [AUTHORITATIVE OUTCOME]. If the persona rule (src/index.ts:540-546) isn't honored, the model could double-execute.
  • record_vendor_quote/submit_revised_quote create the PO inline; submit_quote defers PO to lock-in. complete_job's BC receive/invoice is gated by BC_AUTO_POST_INVOICE (default OFF).
  • Photo tool-choice trap. update_ticket_details does NOT write images; only upload_issue_images/upload_vendor_photos do.
  • Custom-API writes are fire-and-forget. The propMaint mirror swallows errors; the BC card can silently drift from the attachment source of truth.