Elegant IT
Works  /  OfficeSyncPro
Client: OfficeSyncPro · Microsoft 365 workspace · AI Integration for Existing Products

OfficeSyncPro now reads the inbox for the team. A person still signs off on every task.

OfficeSyncPro connects Outlook email, calendars and Teams meetings to projects and tasks for small teams that run their client work through Microsoft 365. The product already held every thread. What it could not do was notice the work inside them: the request buried in point 2 of a long reply, the decision made on a call, the question nobody answered. Elegant built the AI pipeline that catches those, turns them into suggestions with owners, dates and the sentence they came from, and never creates anything until a person accepts it.

44tests across 8 suites on PostgreSQL 17, with every model and Graph reply recorded, so CI never touches the network
0tasks, invites or reminders created until a person accepts the suggestion
0model calls for newsletters, auto-replies and calendar responses, filtered from headers first
4model routes through OpenRouter, each with a fallback, so one provider outage stops nothing
Node.jsNestJSNext.jsTypeScriptMicrosoft GraphTeams transcriptsOpenRouterClaudeGPT-5 miniGeminizodDrizzle ORMPostgreSQLBullMQJest
A client email opens in OfficeSyncPro. The AI panel classifies it, proposes three tasks with owners and dates, highlights the sentence behind each one, removes a fourth it cannot prove, and offers three meeting times from the team's calendars. One click creates the first task. Demo workspace data.
The product

Outlook was already the project system. It just did not know it.

OfficeSyncPro is built for teams of 5 to 40 people, such as agencies and professional services firms, whose clients live in their inbox. It signs in with Microsoft, syncs mail and calendars through Microsoft Graph, and lets a team link any email to a project or a task, chat in real time and track follow-ups without leaving the Microsoft 365 world.

Elegant designed and built the product. The AI layer was the next step, and the hardest one: it had to work on real client mail, inside a multi-tenant app, with the same trust bar as the rest of the product. A wrong task assigned to a client contact, or a meeting booked at a time nobody is free, costs more goodwill than the feature earns.

ProductOfficeSyncPro, Microsoft 365 workspace for client teams
StackNestJS on Node.js, Next.js, PostgreSQL, Redis and BullMQ
Data sourceMicrosoft Graph: mail, calendars, Teams transcripts
ModelsOpenRouter: Claude, GPT-5 mini, Gemini
Elegant scopeProduct design and build, then the AI pipeline end to end
Where the work hides

One reply, four jobs, and one trap.

This is an ordinary client reply. Read quickly, it says thanks and asks a question. Read properly, it assigns work to two people with two deadlines, records a scope decision, asks for a meeting, and, in the quoted history underneath, contains an older request that is already handled. A tool that reads the whole thing as one blob creates a duplicate task from history. A tool that reads nothing leaves all of it to memory.

1

A task for Dana, due Friday 25 Sep

"By Friday" in an email sent on Wednesday 23 Sep. The date is resolved in code, in the workspace time zone.

2

A decision, and the task it creates

The blog migration is out of scope. The SOW needs editing. No date was given, so none is invented.

3

A task for Omar, who is only on cc

He is on the thread and in the workspace, so he can be the owner. Someone who is neither never can.

4

A meeting request

Answered with 3 times both Dana and Omar are free next week, read from their Outlook calendars.

x

The trap: quoted history

The Q4 pricing line was written by Dana, a day earlier. The pipeline reads only the new part of a reply, and a suggestion that quotes anything else is removed.

Models are good at reading. They are not trusted to decide. Every suggestion has to point at the exact sentence it came from, and a person makes the final call.
Architecture

A queue job per Graph notification, and a review queue at the end.

OfficeSyncPro already subscribed to Microsoft Graph change notifications for every connected mailbox and calendar. The AI pipeline hangs off the same subscription: each notification becomes a BullMQ job, and the job runs the message through the stages below. Graph delivers at least once, so the first thing a job does is claim the message by its id. A redelivered notification finds the claim and stops before a single token is spent.

Four lanes, one destination. Email, Teams transcripts, quiet threads and plain-language scheduling requests each produce suggestions in the same review queue, with the same shape and the same accept, edit and dismiss actions. The product never has to learn four different AI features.

Violet stages call a model. Lime stages are code that checks what a model returned. Everything else is plain TypeScript, and nothing reaches the product without passing through the review queue.
src/ai/pipeline.ts · one Graph change notification, end to end/** * Runs for every Microsoft Graph change notification on a mailbox, as a queue job. * Graph delivers at least once, so the first thing it does is make sure this message * has not been processed already. Nothing it produces is visible as a task until a * person accepts it from the review queue. */ export async function processEmail(deps: ModelDeps, ctx: Ctx, msg: GraphMessage): Promise<Trace> { const trace: Trace = { messageId: msg.id, outcome: 'duplicate', suggestions: [], schedulingAsk: false }; const [item] = await deps.db.insert(aiItems) .values({ tenantId: ctx.tenantId, source: 'email', sourceId: msg.id, conversationId: msg.conversationId }) .onConflictDoNothing().returning({ id: aiItems.id }); if (!item) return trace; const skip = prefilter(msg); if (skip) { await deps.db.update(aiItems).set({ skippedReason: skip }).where(eq(aiItems.id, item.id)); return { ...trace, outcome: 'skipped', skippedReason: skip }; } const { text, participants } = prepare(msg); const t = await triage(deps, ctx, msg.subject, text); await deps.db.update(aiItems).set({ kind: t.data.kind, priority: t.data.priority, needsReply: t.data.needs_reply }) .where(and(eq(aiItems.id, item.id), eq(aiItems.tenantId, ctx.tenantId))); trace.outcome = 'triaged'; trace.triage = { kind: t.data.kind, priority: t.data.priority, needsReply: t.data.needs_reply, model: t.model }; trace.schedulingAsk = t.data.has_scheduling_ask; if (!['request', 'decision'].includes(t.data.kind)) return trace; const e = await extract(deps, ctx, msg.subject, text); const { kept, dropped } = verifyCommitments(e.data, text, participants, ctx, new Date(msg.sentDateTime), msg.conversationId); trace.extraction = { model: e.model, kept: kept.length, dropped }; for (const s of kept) { const [row] = await deps.db.insert(aiSuggestions).values({ tenantId: ctx.tenantId, itemId: item.id, type: 'task', dedupKey: s.dedupKey, evidence: s.quote, flags: s.flags, payload: { title: s.title, ownerId: s.ownerId, ownerEmail: s.ownerEmail, due: s.due, dueText: s.dueText, quote: s.quote, priority: t.data.priority }, }).onConflictDoNothing().returning({ id: aiSuggestions.id }); if (row) trace.suggestions.push({ title: s.title, owner: s.ownerEmail, due: s.due, flags: s.flags }); } trace.outcome = trace.suggestions.length ? 'suggested' : 'triaged'; return trace; }
Seven jobs, one pipeline

What the AI does, and what it is not allowed to do.

01Triage

Read less, then read carefully.

Most of an inbox needs no intelligence at all. Newsletters, receipts, auto-replies and meeting acceptances are identified from their headers and sender before anything leaves the server, so they cost nothing and expose nothing. What remains goes to a small, fast model that answers four questions in a fixed shape: what kind of message is this, how urgent is it, does the sender expect a reply, and does it ask for a meeting. Only requests and decisions continue to extraction, which runs on the strongest model in the route.

src/ai/prefilter.ts · decided from headers, before anything leaves the server/** * Mail no model needs to read. Checked from headers and sender before anything is * sent anywhere: in a normal inbox this is the largest share of messages, and every * one skipped here is a model call that never happens. */ export function prefilter(msg: GraphMessage): string | null { const h = new Map((msg.internetMessageHeaders ?? []).map((x) => [x.name.toLowerCase(), x.value.toLowerCase()])); const from = msg.from.emailAddress.address.toLowerCase(); if (h.has('list-unsubscribe') || h.has('list-id')) return 'mailing_list'; const auto = h.get('auto-submitted'); if (auto && auto !== 'no') return 'auto_submitted'; if (['bulk', 'list', 'junk'].includes(h.get('precedence') ?? '')) return 'bulk'; if (/^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|notifications?)@/.test(from)) return 'automated_sender'; if (/^(accepted|declined|tentative|canceled|cancelled):/i.test(msg.subject.trim())) return 'calendar_response'; return null; }
02Extraction

Commitments, with receipts.

The extraction model returns each commitment with a title, an owner if the email makes one clear, the deadline exactly as written, and the sentence it came from, copied verbatim. Then code takes over. The quote must appear in the new part of the message, or the suggestion is removed. The owner must be on the thread or in the workspace, or the owner is cleared and the suggestion is flagged. A commitment is keyed on the thread and its quote, so the same sentence can never produce two tasks. It is the same rule we built into SaleQue's CRM assistant: no source, no sentence.

src/ai/extract.ts · the model proposes, this function decides what survives/** * The model proposes, this function decides what survives. A commitment is kept only * if its quote really appears in the email. An owner is kept only if they are on the * thread or in the workspace: text inside an email must never be able to assign work * to an arbitrary address. Dates are resolved here, not by the model. */ export function verifyCommitments( raw: z.infer<typeof Extraction>, text: string, participants: string[], ctx: Ctx, sentAt: Date, conversationId: string, ): { kept: TaskSuggestion[]; dropped: { title: string; reason: string }[] } { const haystack = normalise(text); const kept: TaskSuggestion[] = []; const dropped: { title: string; reason: string }[] = []; const seen = new Set<string>(); for (const c of raw.commitments) { if (!haystack.includes(normalise(c.quote))) { dropped.push({ title: c.title, reason: 'quote_not_in_source' }); continue; } const flags: string[] = []; let ownerEmail = c.owner_email?.toLowerCase() ?? null; const member = ctx.members.find((m) => m.email.toLowerCase() === ownerEmail); if (ownerEmail && !member && !participants.includes(ownerEmail)) { flags.push('owner_outside_thread'); ownerEmail = null; } const due = resolveDue(c.due_text, sentAt, ctx.timeZone); if (c.due_text && !due) flags.push('due_unresolved'); const dedupKey = createHash('sha256').update(`${ctx.tenantId}|${conversationId}|${normalise(c.quote)}`).digest('hex'); if (seen.has(dedupKey)) continue; seen.add(dedupKey); kept.push({ title: c.title, ownerId: member?.id ?? null, ownerEmail, due, dueText: c.due_text, quote: c.quote, flags, dedupKey }); } return { kept, dropped }; }
03Deadlines

A date is arithmetic, not a guess.

Models are unreliable at calendar maths, and time zones make it worse: "tomorrow" in an email sent at 22:30 in New York is already the day after in UTC. So the model copies the phrase and a small, tested function turns it into a date, relative to when the message was sent and in the workspace time zone. When a phrase is genuinely ambiguous, the function refuses. "Next Tuesday" means different days to different people, so the task keeps the words and asks.

"by Friday"sent Wed 23 Sep, 10:12 New York2026-09-25
"end of week"that week's Friday2026-09-25
"Tuesday next week"the Tuesday after next Monday2026-09-29
"tomorrow"sent Wed 22:30 New York, Thu 02:30 UTC2026-09-24
"5 January"already past this year2027-01-05
"next Tuesday"ambiguous, kept as writtenasks
04Meetings

Every meeting ends with a list.

When a Teams meeting has a transcript, Graph serves it as WebVTT with a speaker on every line. The pipeline groups it into turns and asks the strongest model for a summary, the decisions and the action items, each with the exact words that show it. The same rule applies as for email: a decision nobody said is dropped. Owners are matched to attendees by name, and "by Friday" is resolved against the meeting date, not the day the summary was read.

05Follow-ups

The thread that went quiet.

OfficeSyncPro already detected replies to tracked emails. The AI adds the part a rule cannot know: whether the last message actually asked for an answer. Triage records that when the message goes out, so the follow-up check itself needs no model at all. If a question from the team has had no reply for longer than the workspace allows, counted in working days, a reminder appears in the queue. A question sent on Friday afternoon is 2 days old on Tuesday, not 4.

06Scheduling

A meeting from one sentence.

"30 minutes next week with you and Omar" is a request any assistant can read. The mistake is letting the model answer it. Here the model only parses the sentence: who, how long, when, and whether mornings or afternoons were asked for. Who is free comes from Microsoft Graph's findMeetingTimes, across the attendees' real Outlook calendars, and the pipeline can only offer slots Graph returned. If a name matches two people, it stops and asks.

src/ai/schedule.ts · the model parses the sentence, Graph answers who is free/** * The model only reads the request. Who is free, and when, comes from Graph. A slot * that Graph did not return can never be offered, and an attendee the directory * cannot pin down stops the flow with a question instead of inviting the wrong Sam. */ export async function proposeSlots( deps: ModelDeps & { graph: GraphCalendar }, ctx: Ctx, request: string, organizer: Member, ): Promise<ScheduleResult> { const { data: ask } = await complete(deps, ctx, 'schedule', SchedulingAsk, SYSTEM, `Organizer: ${organizer.name} <${organizer.email}>. "you" and "me" refer to the organizer.\n\n${request}`); const attendees: Member[] = []; const external: string[] = []; for (const written of ask.attendees) { const hit = resolvePerson(written, ctx.members); if (Array.isArray(hit)) return { status: 'clarify', name: written, candidates: hit }; if (hit) { if (hit.id !== organizer.id && !attendees.some((a) => a.id === hit.id)) attendees.push(hit); } else if (written.includes('@')) external.push(written.toLowerCase()); else if (!['you', 'me'].includes(written.trim().toLowerCase())) return { status: 'clarify', name: written, candidates: [] }; } const tz = WINDOWS_ZONE[ctx.timeZone] ?? 'UTC'; const [from, to] = ask.part_of_day === 'morning' ? ['09:00', '12:00'] : ask.part_of_day === 'afternoon' ? ['13:00', '17:00'] : ['09:00', '17:00']; const res = await deps.graph.findMeetingTimes({ attendees: attendees.map((a) => ({ type: 'required', emailAddress: { address: a.email, name: a.name } })), timeConstraint: { activityDomain: 'work', timeSlots: windowDays(ask.window_text, ctx.now, ctx.timeZone).map((date) => ({ start: { dateTime: `${date}T${from}:00`, timeZone: tz }, end: { dateTime: `${date}T${to}:00`, timeZone: tz }, })), }, meetingDuration: `PT${ask.duration_minutes}M`, maxCandidates: 10, isOrganizerOptional: false, returnSuggestionReasons: true, minimumAttendeePercentage: 100, }, ctx.timeZone); const slots = res.meetingTimeSuggestions .filter((s) => s.organizerAvailability === 'free') .sort((a, b) => b.confidence - a.confidence || a.meetingTimeSlot.start.dateTime.localeCompare(b.meetingTimeSlot.start.dateTime)) .slice(0, 3) .map((s) => s.meetingTimeSlot) .sort((a, b) => a.start.dateTime.localeCompare(b.start.dateTime)); if (!slots.length) return { status: 'none', reason: res.emptySuggestionsReason || 'no_common_free_time' }; return { status: 'slots', title: ask.title, attendees, external, slots }; }
07Review

Nothing happens until a person says so.

Every lane ends in the same queue. Each suggestion shows where it came from, the sentence behind it, and any flag the verifier raised. Accept creates the real task, reminder or invite, with the owner and date filled in and the source sentence attached, so anyone opening the task later can see why it exists. Edit changes it first. Dismiss removes it for good. Accepting twice creates one task, and a suggestion from one workspace cannot be accepted from another.

src/ai/review.ts · the only path from a suggestion to a task/** * The only path from a suggestion to a real task, and it starts with a person's click. * Scoped by tenant in the query itself, idempotent on double-submit, and a dismissed * suggestion can never be revived by a stale tab. */ export async function acceptSuggestion( db: DB, ctx: Ctx, id: string, userId: string, tasks: TaskWriter, edits: { title?: string; assigneeId?: string | null; dueDate?: string | null } = {}, ): Promise<{ taskId: string }> { return db.transaction(async (tx) => { const [s] = await tx.select().from(aiSuggestions) .where(and(eq(aiSuggestions.id, id), eq(aiSuggestions.tenantId, ctx.tenantId))).for('update'); if (!s) throw new NotFound(id); if (s.status === 'accepted' && s.createdTaskId) return { taskId: s.createdTaskId }; if (s.status !== 'pending') throw new AlreadyDecided(id); const p = s.payload as { title: string; ownerId: string | null; due: string | null; quote: string }; const task = await tasks.create({ tenantId: ctx.tenantId, title: edits.title ?? p.title, assigneeId: edits.assigneeId !== undefined ? edits.assigneeId : p.ownerId, dueDate: edits.dueDate !== undefined ? edits.dueDate : p.due, sourceQuote: p.quote, }); await tx.update(aiSuggestions) .set({ status: 'accepted', decidedBy: userId, decidedAt: new Date(), createdTaskId: task.id }) .where(eq(aiSuggestions.id, id)); return { taskId: task.id }; }); }
Guardrails

Client mail is hostile input. We built it that way.

An inbox is the one place where anyone on the internet can put text in front of the model. So the pipeline assumes some of that text is an attack, and makes sure an attack has nowhere to go.

PROMPT INJECTION

Instructions in an email are just words

Every prompt wraps the content as data and says so. More important, nothing depends on the model obeying that: the verifier strips any owner outside the thread and the workspace, only a fixed set of action types exists, and nothing is created without a click. The test suite sends an email that orders the assistant to export the client list to an outside address. It ends up as an unassigned, flagged suggestion that a person dismisses.

DATA POLICY

No provider keeps the mail

Every request to OpenRouter sets data_collection to deny, so it is only routed to providers that do not store or train on prompts, and require_parameters, so a provider that cannot honour the strict JSON schema is never picked. The model sees the new part of one message, not the mailbox.

CONTRACTS

A reply that does not fit is refused

Each stage has a zod schema that is sent to the model as a strict JSON schema and checked again on the way back. A reply that fails gets one repair attempt with the error attached. A second failure throws a retryable error and the queue runs the job later. Malformed output never reaches the database.

ISOLATION AND REPLAY

One workspace, one pass per message

Every row the pipeline writes carries the workspace id, and every read and update filters on it, matching how the rest of OfficeSyncPro isolates tenants. Messages are claimed by their Graph id and suggestions by their source sentence, so redelivery and retries are safe by construction.

WHY NOT MICROSOFT COPILOT

Every seat gets the AI, not only the licensed ones

Microsoft's Copilot Chat and Retrieval APIs answer only for users who hold a Microsoft 365 Copilot add-on license, and most OfficeSyncPro workspaces are small teams that do not pay for one. Building on OpenRouter gives every user the same AI, lets the team pick the model for each job, and keeps the strict schemas, fallbacks and no-retention routing above under OfficeSyncPro's control. Teams that already run Copilot lose nothing: the pipeline reads the same Microsoft Graph data Copilot does.

Model routing

The right model for each job, and a second one ready.

OpenRouter lets one request name several models in priority order and falls back when a provider is down, rate limited or refuses. The route for each stage is configuration, so changing a model is a deploy of a config value, not a rebuild. Every call is written to a ledger with the model that actually answered, its tokens and its cost, which is how the team knows what the feature costs per workspace.

StagePrimaryFallbackWhy this model
Triageopenai/gpt-5-minigoogle/gemini-2.5-flashRuns on every message that survives the prefilter. Small, fast and cheap is the requirement.
Extraction~anthropic/claude-sonnet-latestopenai/gpt-5Decides what a person is asked to act on. Precision matters more than cost.
Meeting notes~anthropic/claude-sonnet-latestgoogle/gemini-2.5-proLong transcripts, several speakers, decisions that must be quoted exactly.
Schedulingopenai/gpt-5-minianthropic/claude-haiku-4.5Only parses a sentence. Availability comes from Graph, never from the model.
src/ai/openrouter.ts · routes per stage, and what every request sends/** * Which models serve each stage, in priority order. OpenRouter tries the next one * when a provider is down, rate limited or refuses, so a single vendor outage does * not stop the pipeline, and changing a model is a config change, not a rebuild. * Triage runs on every message, so it gets a small fast model. Extraction and * meeting summaries decide what a person will be asked to act on, so they get the * strongest one. */ export const ROUTES: Record<Stage, { models: string[]; maxTokens: number }> = { triage: { models: ['openai/gpt-5-mini', 'google/gemini-2.5-flash'], maxTokens: 300 }, extract: { models: ['~anthropic/claude-sonnet-latest', 'openai/gpt-5'], maxTokens: 1200 }, meeting: { models: ['~anthropic/claude-sonnet-latest', 'google/gemini-2.5-pro'], maxTokens: 2000 }, schedule: { models: ['openai/gpt-5-mini', 'anthropic/claude-haiku-4.5'], maxTokens: 400 }, }; // inside complete(), the request every stage makes body: JSON.stringify({ models: route.models, messages, max_tokens: route.maxTokens, temperature: 0, response_format: { type: 'json_schema', json_schema: { name: stage, strict: true, schema: z.toJSONSchema(schema) } }, provider: { data_collection: 'deny', require_parameters: true }, usage: { include: true }, }), });
The run

The whole inbox, replayed.

The reference build ships with the demo inbox from this page and a trace command that runs it through every stage. The newsletter never reaches a model. The invoice stops after triage. The client reply produces three tasks and loses the one from quoted history. The phishing email becomes a flagged, unassigned suggestion. The meeting ends with two decisions, three tasks and one removed claim, and the scheduling request comes back with three real slots.

The reference build's test suite and the pipeline trace, captured from a real run. 44 tests pass on PostgreSQL 17. Model and Graph replies are recorded payloads, so the run is deterministic and needs no network.
officesync-ai · npm run tracezsh
officesync-ai  replay  workspace 0b6f4c1a  tz America/New_York  models via OpenRouter (recorded)

I2TG94AA  This week in design systems  digest@news.designweekly.example
  prefilter  skipped: mailing_list  0 model calls

I2TG95AA  Invoice 2041 paid  megan@harlowpike.example
  triage     fyi · low  gpt-5-mini

I2TG93AA  RE: Phase 2 sign-off  megan@harlowpike.example
  triage     request · high · needs reply · scheduling ask  gpt-5-mini
  extract    3 kept, 1 dropped  claude-sonnet-5
    + Send revised homepage wireframes to Megan   Dana  due 2026-09-25
    + Remove the blog migration from the phase 2 SOW   Dana
    + Confirm analytics tagging on the checkout pages   Omar  due 2026-09-25
    - Send Q4 pricing to Megan  quote_not_in_source

I2TG96AA  Urgent: account verification  it-desk@harlowpike-support.example
  triage     request · high  gpt-5-mini
  extract    1 kept, 0 dropped  claude-sonnet-5
    + Export the full client list   unassigned  due 2026-09-23  owner_outside_thread

teams     Phase 2 kickoff  transcript, 7 turns
  decided  The homepage hero leads with the booking flow.
  decided  The blog migration moves to phase 3.
    + Rework the homepage wireframes with a booking-first hero   Priya  due 2026-09-25
    + Fix the checkout purchase event and send Megan a test report   Omar
    + Update the SOW and send it to Megan   Dana  due 2026-09-23
    - decision: Launch date stays at 14 November.  not said in the meeting

schedule  "30 minutes next week with you and Omar"  free/busy from Graph findMeetingTimes
     Mon, 28 Sep 14:00 ET
     Tue, 29 Sep 10:00 ET
     Thu, 01 Oct 15:30 ET

review queue  8 pending  · 0 tasks created until a person accepts
ledger        7 model calls  $0.0262 total, recorded prices
Verified

What the suite proves on every commit.

Quoted history never becomes a task.test/pipeline.spec.ts
Every suggestion waits for a person.test/pipeline.spec.ts
A redelivered notification costs no model call.test/pipeline.spec.ts
Newsletters and automated mail skip the models.test/prepare.spec.ts
An email cannot assign work outside the thread.test/pipeline.spec.ts
Deadlines resolve in the workspace time zone.test/due-date.spec.ts
Ambiguous dates and names are asked, not guessed.test/due-date.spec.ts · test/schedule.spec.ts
Only slots Graph returned are offered.test/schedule.spec.ts
Meeting decisions nobody said are dropped.test/meeting.spec.ts
Reminders count working days only.test/followup.spec.ts
Fallbacks, retries and schema repair are recorded.test/openrouter.spec.ts
Accept is idempotent and scoped to one workspace.test/review.spec.ts
What the numbers on this page are. The test counts, trace output and model costs come from the reference build described above, run on demo workspace data with recorded model and Graph replies. The people, companies and messages are fictional. They show how the pipeline behaves, not OfficeSyncPro's production traffic, which is theirs to publish.
Delivered to OfficeSyncPro

An AI layer that fits the product it joined.

Graph ingestionNotifications to BullMQ jobs on the existing subscriptions, claimed by message id
Triage and extractionPrefilter, triage, commitment extraction, the verifier and the date resolver
Meeting intelligenceTeams transcript parsing, summaries, decisions and owned action items
Follow-ups and schedulingQuiet-thread reminders and plain-language scheduling on findMeetingTimes
Review queueOne queue for every lane, with accept, edit and dismiss wired to real tasks
Routing and ledgerOpenRouter routes with fallbacks, schema contracts, per-call cost ledger, 44 tests
Tech stack
NestJS on Node.js 22The existing OfficeSyncPro API. The AI pipeline is one more module and one more queue.
Next.js and ReactThe suggestions panel and the review queue, in the product's own components.
Microsoft GraphMail and calendar change notifications, Teams transcripts, findMeetingTimes, MSAL auth.
OpenRouterClaude, GPT-5 mini and Gemini behind one API, with fallbacks and no-retention routing.
PostgreSQL, Drizzle, BullMQSuggestions, claims and the ledger in the product database. Jobs on Redis.
zod and JestA strict contract per stage, and 44 tests on a real Postgres with recorded replies.
Takeaway

The model reads. The product decides.

The interesting engineering in this project is not a prompt. It is everything around the model: deciding what it never needs to see, proving each suggestion against the source, doing date and calendar work in code, and making sure a person is the last step. That is what lets an AI feature run on real client mail without anyone worrying about what it might do.

If your product already holds email, meetings or documents your users keep sorting by hand, this kind of layer is a fixed-price build on top of what you have.

The service behind this build

AI Integration for Existing Products

AI features built into a product that is already live, on its own data and stack, by one senior team. Fixed price, fixed timeline, full IP transfer.

Building agents and automations from scratch? See AI Agents and Workflow Automation

Ready to build something like this?

Book a free 15-minute call. We will look at what you are building and tell you honestly what it takes and whether we are the right fit.