Skip to content
47% off: the complete course for $79 (reg. $149)Order now →
AI Builder
Module 4Full course10 min read

Tools That Read and Write Your App's Data

Expose your app's existing API as model tools with the user's own permissions, confirmation steps for writes, and audit logs for everything.

Read-only tools let a model answer questions. Write tools let it change things: assign a ticket, add a tag, close a conversation, issue a credit. That's where an AI feature starts saving people real time, and where a mistake stops being a wrong sentence and becomes a wrong change in someone's account.

The good news is that you've already solved most of this problem. Your app has an API with authentication, authorization, validation, and business rules. The job is to expose that API to the model without bypassing any of it. In this lesson you'll build Deskmate's "assistant actions": tools that run with the signed-in user's own permissions, writes that become proposals the user confirms, protection against double-applies and stale data, and an audit log that records everything the AI did and on whose behalf.

Wrap your service layer, not your database

The tempting shortcut is to give the model direct database access ("here's a SQL tool") or to write new, AI-specific functions that touch tables directly. Both skip the rules your product already enforces: who can edit what, which state transitions are allowed, which webhooks fire, which notifications go out.

Instead, find the layer your REST routes or server actions already call, the service layer, and make tools thin wrappers around it:

LayerWho calls itEnforces
Route handlerBrowser, mobile appSession, request parsing
Service functionRoutes, jobs, and AI toolsAuthorization, business rules, side effects
DatabaseService functions onlyConstraints

If your codebase doesn't have a clean service layer yet, building one for the functions you want to expose is worth doing before you add AI writes. It pays for itself the first time a rule changes and you only update it in one place.

Here's a minimal Deskmate ticket service. The details will differ in your app; the shape is what matters: every function takes an actor (the authenticated user) as its first argument and enforces rules itself.

src/services/tickets.ts
import { sql } from "@/db";
 
export type Actor = { userId: string; workspaceId: string; role: "viewer" | "agent" | "lead" };
 
export class ForbiddenError extends Error {}
export class NotFoundError extends Error {}
 
export type TicketUpdate = {
  status?: "open" | "pending" | "solved";
  assigneeTeam?: string;
  addTags?: string[];
};
 
// The same service functions your REST routes already call.
export async function getTicket(actor: Actor, ticketId: string) {
  const [ticket] = await sql<{ id: string; subject: string; status: string; assignee_team: string | null; version: number }[]>`
    select id, subject, status, assignee_team, version from tickets
    where id = ${ticketId} and workspace_id = ${actor.workspaceId}
  `;
  if (!ticket) throw new NotFoundError(`Ticket ${ticketId} not found`);
  return ticket;
}
 
export async function updateTicket(actor: Actor, ticketId: string, update: TicketUpdate, expectedVersion: number) {
  if (actor.role === "viewer") throw new ForbiddenError("Viewers can't edit tickets");
  if (update.status === "solved" && actor.role === "agent") {
    // Example business rule: in this workspace, only leads can close tickets.
    throw new ForbiddenError("Only leads can mark tickets solved");
  }
  const [row] = await sql`
    update tickets set
      status = coalesce(${update.status ?? null}, status),
      assignee_team = coalesce(${update.assigneeTeam ?? null}, assignee_team),
      tags = array(select distinct unnest(tags || ${update.addTags ?? []}::text[])),
      version = version + 1
    where id = ${ticketId} and workspace_id = ${actor.workspaceId} and version = ${expectedVersion}
    returning id, version
  `;
  if (!row) throw new Error("Ticket changed since this action was proposed. Review it again.");
  return row;
}

(The src/db.ts module just exports const sql = postgres(process.env.DATABASE_URL!).) Note the version column: every update increments it, and updates only apply if the version still matches. That optimistic concurrency check becomes important once there's a delay between proposing a change and applying it.