Software Design & Development

Junior Devs Can Add AI Without Rewriting the App

Your first AI feature probably has to live inside an application that already handles customer data, permissions, and error cases. I would add it behind the existing request path, not build a separate AI service first, because that path already knows who may read a record. The risky part is deciding exactly which fields may leave it.

A narrow endpoint is safer than a new service because it reuses existing authorization

Suppose a ticket screen needs an AI-generated summary. Start at the existing “view ticket” endpoint. Find where it checks the signed-in user, resolves the tenant, and loads the ticket. Add a separate summary action after those checks rather than letting the browser send ticket text directly to a model provider. That preserves the application’s established access decision and keeps the provider credential out of client code.

Do not pass the database row to the provider just because the user can view it. A row may contain internal notes, email addresses, audit fields, or data added later by a migration. Create a small projection function that accepts a ticket and returns only the approved fields, such as subject and body. Give that function a name reviewers can find, such as toAiInput, and call it immediately before the provider adapter. An allowlist is preferable to deleting known secrets because a newly added database column will otherwise become part of the outbound payload without anyone changing the AI code.

Keep the endpoint’s response narrow too. Return a summary or a typed failure, not the provider’s raw response object. Validate the result against JSON Schema Draft 2020-12 or a Zod 3 schema before saving or displaying it. If the UI renders generated text as HTML, use its normal escaping path; a prompt telling the model to be harmless is not an output-encoding mechanism. OWASP’s LLM Top 10 is useful during review because it gives the team names for prompt injection and sensitive-information disclosure, but the review still needs to identify the fields in this particular application.

I would not create a shared “AI client” that every controller can call with arbitrary objects, because it makes the approved outbound data boundary hard to locate. The first integration should make an unauthorized field difficult to send by accident, even if that means writing a small adapter that initially serves only one endpoint.

Characterization tests should pin the boundary, not every legacy response byte

Before editing the endpoint, write tests for what it already guarantees: a user in another tenant cannot fetch the ticket, an ordinary user cannot read an internal note, and a missing ticket produces the application’s usual error. These are characterization tests, not a request to clean up the controller. Use the project’s current test runner—Jest 29, for example—if it is already installed. Adding Node.js 20’s built-in node:test solely for this feature would impose a second test convention without improving the boundary.

Then add tests for the new projection. This example runs as node --test ai-input.test.mjs on Node.js 20; in the application, replace the inline function with an import from the production module:

import test from 'node:test';
import assert from 'node:assert/strict';
const toAiInput = ({ subject, body }) => ({ subject, body });

test('AI input contains only approved ticket fields', () => {
  const ticket = { subject: 'Refund', body: 'Check order',
    internalNote: 'Password reset token', customerEmail: 'a@example.com' };
  const payload = toAiInput(ticket);
  assert.deepEqual(payload, { subject: 'Refund', body: 'Check order' });
  assert.equal(JSON.stringify(payload).includes('Password reset token'), false);
});

This test is deliberately smaller than an endpoint snapshot: it fails if the projection gains a field, while it does not fail because unrelated response formatting changes. Use Add regression tests to legacy code without rewriting it QA playbook for the broader testing approach, but put your first AI-specific assertion at the outbound payload boundary because that is where an approved record can become an unapproved transfer.

At the HTTP level, stub the provider rather than making a live call in CI. A stub lets the test inspect the exact request body and simulate a timeout or malformed response without depending on provider availability. In a browser-heavy app, MSW 2 can intercept the HTTP call; in an Express 4 server, an injected provider adapter may be simpler because the test can replace one function. Whichever method fits the repository, assert that a cross-tenant request makes no provider call. A 403 response alone does not prove that the forbidden ticket was never sent.

A feature flag wins first; a separate service wins only with an actual ownership boundary

There are two reasonable deployment options. An in-process endpoint behind a feature flag wins when the existing application owns ticket authorization and the first use case is small. Its costs are additional latency in that application and a provider dependency its maintainers must operate. A separate AI service wins when multiple applications already need the same controlled data exchange and a team can own the service contract. Its costs are another deployment, another network hop, and an authorization design that cannot simply assume the caller was allowed to read the record.

For this first ticket summary, choose the flagged endpoint. Start with a configurable 1% rollout cohort of eligible users so an operator can stop new calls without reverting unrelated code. Set an initial, adjustable 1,500 ms provider timeout; if it fires, return “summary unavailable” while leaving the ordinary ticket view intact. Apply a trial ceiling of 50 requests per hour per tenant at the server, since a browser-only limit cannot prevent direct calls. These are starting settings to tune against your traffic and provider limits, not universal performance targets.

Do not use “shadow mode” with real ticket bodies as an apparently harmless rehearsal. Shadow requests still transmit data, even when users never see the generated result. Exercise the adapter first with synthetic tickets, then enable real requests only after the outbound-field test, access test, and logging review pass. That sequence gives the team a chance to catch an unwanted field before production records reach a provider.

Keep the flag check outside the projection and provider call. When the flag is off, the endpoint should make no outbound request. When it is on, the request should use the same tenant-scoped lookup as the existing view path. This makes both states straightforward to test and prevents a future refactor from moving authorization into a provider-specific helper where application developers are less likely to look.

Production checks should observe the call without recording the ticket

After release, a passing test suite cannot show what was actually sent by a newly added caller or whether retries have multiplied provider calls. Treat Your First AI Feature May Leak Data After It Ships as a useful challenge to the launch checklist, but respond with checks on this endpoint’s data path rather than a general warning to “watch the logs.”

Instrument the adapter with OpenTelemetry spans and Prometheus counters for attempted calls, rejected requests, provider failures, and timeouts. Record a route name, outcome, and tenant identifier only if your logging policy permits that identifier; do not attach prompts, model output, ticket IDs, or provider authorization headers to traces. Check the HTTP client’s debug setting as well as application logging, since either can expose request bodies. A trace that omits content can still answer whether a denied request reached the adapter and how long an allowed call took.

Put a Grafana panel beside the existing endpoint dashboard. Measure the ordinary ticket view’s current p95 latency over a representative 7-day baseline before judging the new route; otherwise a slow provider call may be blamed for latency the old path already had. Set a provisional alert when provider failures exceed 2% of AI requests over 30 minutes, then adjust it after observing real volume so a handful of calls does not create a misleading page. Track request counts as well as percentages for that reason.

Finally, check storage and deletion behavior. Decide whether summaries are transient or saved, and verify that the application’s existing deletion path removes any saved summary. Confirm the provider account’s retention controls with the account owner rather than inferring them from an SDK default. If the provider response is malformed, return a controlled error in the application’s established format—RFC 9457 if that is already its convention—and keep the ticket itself available. A summary is optional; access to the original record is not.

The first commit should make one forbidden field impossible to send

Find the current ticket lookup and write down the authorization check it uses. Then add a projection function beside that path and a failing test proving internalNote cannot appear in its output. Make the test pass before wiring up a provider. That small commit gives reviewers a concrete boundary to inspect and gives the next developer a safe place to extend the feature.