Lessons from adding AI to a live ColdFusion back office without rewriting it.
Most advice about adding AI to a business application starts with the AI: which model, which framework, which prompt. We found that the wrong place to start. The application already holds your data, users, permissions and workflow. The hard part is connecting a model to it so that it is safe to run unattended, cheap enough to run all day, and easy to switch off.
This is how we did that for a ColdFusion application a subcontractor uses to run its jobs. Staff were checking purchase orders and new-order notices from several homebuilders’ portals by hand. We built automations around the application and left its core alone. The repository now holds sixteen of them, and only three call a language model. That ratio is one of the lessons.
The shape of the integration
Three parts, one job each:
- The application stays the system of record. We added two endpoints: one lists work, one accepts results.
- n8n schedules runs, fetches inputs, hands them to a container and reports back.
- A task container does the work: a few small scripts and, where reading is needed, one model call.
One cycle looks like this:
- n8n asks the application for the tasks that are waiting.
- n8n downloads the attached PDF.
- n8n sends the PDF to the task container to run a cycle (scripts plus one model call).
- The container returns a result document.
- n8n posts the result back to the application.
The application and the container never talk to each other. Everything passes through n8n. That one rule explains most of what follows.
1. Give the application one narrow door
We had n8n poll the application rather than the application call out. That meant one read endpoint and one write endpoint, not outbound webhooks with their own retries and failure handling. For a ColdFusion codebase, that was a small change.
Both endpoints use a scoped service token, not a login as a bot user, which would bring passwords, session expiry and re-login. If the token isn’t configured, every request is rejected. Each purpose gets its own token, so they can be rotated independently.
2. Use the application’s own concepts as the control panel
Tasks that need an automated check are assigned to a dedicated bot user in the ordinary task screen. Assigning opts a task in; un-assigning opts it out. That is a kill switch and a gradual-rollout lever with no feature flag, and operators already know how to use it.
Selection is by state, not by lease. The application mints a run ID when it hands a task out but doesn’t store it, so a crashed run leaves nothing behind and the task reappears in the next listing.
Our first selection query had no date bound, on the reasoning that a purpose-built account would be empty. It wasn’t. The account we planned to use already held 32,258 tasks, 7,193 of them with a PO attached. The first scheduled run would have sent all of those to the model, an estimated $12,800, and taken each task out of a real person’s queue for good.
The fix was to list only tasks whose start date is today. The trade-off is deliberate: a task not picked up that day stays unpicked until a person resets its date. That fails safe. When the unit of work costs money and writes data, the selection query is the most dangerous code in the integration.
3. Keep credentials with the orchestrator
n8n owns the schedule, the retries and every outbound credential. Containers hold no application token. Each automation has its own container, data directory, explicit environment allowlist (never a blanket env file) and trigger token. Who holds what:
- Application API token: held by n8n, never reaches any task container.
- Model API key: held only by the containers that call a model, never reaches n8n or other containers.
- Trigger token (n8n to container): held by n8n and that one container, never reaches the application.
The token that lets the system push notifications into the application, for example, is deliberately absent from the container that runs the model.
4. Let the model read; let code act
The model reads text and returns fields. Everything with a side effect (signing in, saving state, writing results, talking to the application) is a small reviewed program that checks its input against an allowlist. A model’s output isn’t reproducible byte for byte: fine for reading a table, not fine for writing to production.
Where the model runs as an agent (Claude Code), it gets a short allowlist of exact commands in a version-controlled file, kept out of n8n’s interface so nobody can widen it without review. Where we call a model as a plain API, hand-written validation does that job.
Three incidents shaped the rules:
- A denial is about one string, not the tool. Twice a cycle was abandoned after the model appended an extra echo command to an allowed command, was denied, and concluded the shell was unavailable. The prompt now says to retry the exact string.
- Keep arguments small, and mark work done only after the write succeeds. A cycle with 15 new orders built one 9 to 10 KB argument that was denied after the orders were already marked as seen, so they were lost. Each item now gets its own call.
- Don’t ask the model for what the code knows. A run reported its duration as minus 86,268,049 milliseconds because the model has no clock. Timestamps now come from the system.
5. Do the mechanical work first
- Decode files outside the model. One cycle opened the raw request to read an ID, and the base64 PDF came along: about 195,000 tokens before any real work. A script now hands the model a metadata file without the blob.
- Filter before reading. An order poll runs 288 times a day. A script now strips the rows the system already knows before the model reads the page, which cut a “nothing new” cycle from $1.33 to about $0.16.
6. Use a model only where it earns its place
Three of sixteen automations call a model. The rest sign in to a homebuilder’s portal, find the house, download or print a document and attach it. Their inputs are structured, so plain code is cheaper and reproducible.
The model earns its place where input is messy: a PDF that mixes two page templates, or an HTML table whose markup drifts. The model is also a setting, not an architecture. A live test found our small model writing its own parser, which the allowlist denied, and then giving up. We moved to a larger one with a one-line change in n8n.
7. Make every result recorded, idempotent and reviewable
- One key everywhere. The application mints the run ID and it travels unchanged. Guard on both run and task, so a retry or re-run returns “duplicate”, not a second invoice. n8n retries on any non-2xx, so duplicates will happen.
- A failure is a result. An unreadable or wrong-job PDF produces a recorded error and a task in manual review, so a person finds out.
- Record and alert in one request, so a stored result without its alert is impossible by construction. The alert title comes from fixed phrases in the application’s code, not from the model, whose wording drifts.
- A person confirms the last step. Nothing moves to “sent to accounting” without a human.
8. Roll out behind a switch, read-only first
We planned alerts before record creation: an alert can be wrong harmlessly, a record can’t. Workflows are imported inactive and pointed first at a QA copy of the application. The reporting step stays disabled until extraction matches documents with known answers.
One trap cost us three days: editing a node in n8n’s editor changes only the editor copy, while the schedule runs the last published version. After any change, check what the next scheduled run actually used.
9. Be explicit about where the data goes
A hosted model is a new outbound data flow: order and job content leaves your servers every cycle. We recorded that as a decision, and the account holder is checking the portals’ terms. Do this before the first scheduled run.
A checklist you can reuse
- Leave the core alone. One read and one write endpoint.
- Scoped service tokens, one per purpose, failing closed.
- Reuse an existing concept as the opt-in and kill switch.
- Select by state, bound the query, and cost the first run.
- Keep credentials in the orchestrator, one container per automation.
- The model reads, code acts. Validate every input.
- Mechanical work before the model.
- A model only where input is messy.
- One key per result, idempotent writes, failures recorded.
- Read-only first, behind a switch, and write down where your data goes.
Most of the engineering isn’t the model. It is the structure around it: a narrow door in, an owner for every credential, and a person who can say stop.
Join the conversation
Comments
Be the first to share a thought.