Skip to main content

Anatomy of a CSV Import Pipeline — Where 90% of the Work Happens Before the First Write

· 19 min read
Gabriel Paunescu
Founder CTO Neologic

Your CSV is not your data. It's a promise that needs verification, transformation, and three layers of validation before it earns the right to touch your database. Here's how a production import hook turns a tab-delimited file into trusted journal entries.

The Premise

Every data import starts with the same fantasy: upload a file, click import, done. The reality is a defensive pipeline where the database write — the part most people think of as "the import" — is the very last step, and often the simplest one. The hard work is everything that comes before it.

This article dissects a production hook that imports general ledger journal entries from an external accounting system's CSV export. The hook is 300+ lines. Fewer than 20 of those lines actually write to the database.

The Source Data Problem

Before writing a single line of import code, you need to confront an uncomfortable truth: the source data is your responsibility. The external system exported it. Your users downloaded it. Nobody in that chain guarantees it's clean.

In this pipeline, the source is a tab-delimited CSV with columns like Trans #, Date, GL Code, Debit, Credit, Memo, and Class. Each row is a single journal item line. Multiple rows share the same Trans # to form a compound journal entry.

The file could have:

  • Rows with no transaction number (separator lines, subtotals)
  • GL codes that don't exist in your chart of accounts
  • Amounts formatted with US thousands commas (1,250.00)
  • Stray quote characters from copy-paste artifacts
  • Zero-amount lines (e.g., Sales Tax Payable 0.00)

The hook's job isn't to "import a CSV." It's to distill a messy file into trusted financial documents — or reject the entire batch with a clear explanation of why.

The Pipeline

Here's the shape of the import, with the write at the end where it belongs:

Nine steps. Only one writes to the database. Let's walk through them.

Step 1: Validate the Request

Before downloading or parsing anything, validate that the request itself is well-formed:

bob.validator.joi().validateData(
{ fileUrl: bob.dataPayload.fileUrl },
SuperJoi.object({
fileUrl: SuperJoi.string().required()
}),
bob.flowUser.getUserInfo()
)

If there's no file URL, fail immediately. Don't download, don't parse, don't allocate memory. Guard clauses first.

Step 2: Load Reference Data

Before you can validate the CSV, you need to know what "valid" means. That means loading the reference datasets the import will validate against:

// -->Get: all active general ledger accounts
const generalLedgerAccounts = await generalLedgerFlowCollection.docs
.flowQuery()
.user(bob.flowUser)
.addPolicy('canRead')
.cache()
.flowOptions(eventOptions.generalLedgerAccountNaoQueryOptions)
.limit(10000)
.getMany(undefined, 'FinanceInterface.GeneralLedgerAccount')

// -->Get: warehouses
const warehouses = await warehouseFlowCollection.docs
.flowQuery()
.user(bob.flowUser)
.addPolicy('canRead')
.getDocumentStatuses(['active'], 'data')
.cache()
.flowOptions(eventOptions.warehouseNaoQueryOptions)
.limit(100)
.getMany(undefined, 'InventoryManagementInterface.Warehouse')

Notice the .cache() — these datasets are stable reference data. And notice the hard limit. You never query reference data without a ceiling.

Both queries fail fast with descriptive errors if they return empty:

if (generalLedgerAccounts.data.length === 0) {
throw naoFormatErrorById('bad_request', {
reason: 'Failed to find general ledger accounts'
})
}

No GL accounts means no chart of accounts means no point continuing. The error is specific enough that the user knows exactly what to fix.

Step 3: Ensure Preconditions

Some imports depend on system state that might not exist yet. This hook ensures fiscal periods and years are created before any journal entries are written:

await bob.executeRequestOrThrow(
eventOptions.ensureFiscalPeriodsActionCfpQuery, {}
)

This is easy to overlook. If you write a journal entry dated March 2024 and no fiscal period exists for Q1 2024, the entry either fails silently or lands in an undefined period. The hook handles this before it even looks at the CSV.

Step 4: Build the Deduplication Index

For idempotent imports — where re-running the same file shouldn't create duplicates — you need to know what already exists:

const existingJournalEntries = await journalEntryFlowCollection.docs
.flowQuery()
.user(bob.flowUser)
.addPolicy('canRead')
.flowOptions(eventOptions.journalEntryNaoQueryOptions)
.query({ 'data.name': { $regex: '^PIPE-' } })
.projection({ 'data.name': 1 })
.limit(2_000_000)
.getMany(undefined, 'FinanceInterface.JournalEntry')

const existingJournalNameSet: Set<string> = new Set(
existingJournalEntries.data.map((je) => je.data.name)
)

Two things to highlight here. First, the .projection({ 'data.name': 1 }) — we only need the name field, so we only fetch the name field. With 2 million potential records, this is the difference between a 30-second query and a timeout. Second, the Set — O(1) lookup for every deduplication check instead of scanning an array.

Step 5: Parse and Clean

Now — and only now — do we touch the CSV:

const glCsv = await new NaoCsvRunner2().loadFile({
inputFilePath: downloadToLocalTemp.filePath,
delimiter: '\t'
})

const allRowsRaw = await glCsv.readFile({ numberOfLines: Infinity })
const allRows = allRowsRaw.filter(
(row: any) => cleanValue(row['Trans #'])
)

The cleanValue helper does the unglamorous work that prevents downstream failures:

const cleanValue = (v: string | undefined): string =>
(v || '').trim().replace(/"+/g, '')

Trim whitespace. Strip stray quotes. Return an empty string instead of undefined. Every field in the CSV passes through this function before it's used anywhere. It's three lines of code that prevent dozens of edge-case bugs.

The filter step drops rows with no Trans # — subtotal rows, blank lines, section headers. These aren't data. They're presentation artifacts from the source system's export.

Step 6: Preflight Validation — The Gate

This is the most important step in the entire pipeline, and it happens before a single document is created.

The hook scans every row in the file and checks that every GL code exists in the system's chart of accounts:

const existingGlCodes: Set<string> = new Set(
generalLedgerAccounts.data.map((gl) => gl.data.code)
)
const missingGlCodeLines: Map<string, number[]> = new Map()

for (let i = 0; i < allRows.length; i++) {
const glCode = cleanValue(allRows[i]['GL Code'])
if (!existingGlCodes.has(glCode)) {
const lineNumber = i + 2 // +1 for 1-indexed, +1 for header row
if (!missingGlCodeLines.has(glCode)) {
missingGlCodeLines.set(glCode, [])
}
missingGlCodeLines.get(glCode).push(lineNumber)
}
}

If any GL codes are missing, the entire import aborts — with a detailed error listing every invalid code and the exact line numbers where they appear:

if (missingGlCodeLines.size > 0) {
for (const [code, lines] of missingGlCodeLines.entries()) {
naoLogger.error(
`Missing GL code: "${code}" — found on ${lines.length} rows: lines ${lines.join(', ')}`
)
}
throw naoFormatErrorById('bad_request', {
reason: `Import aborted — ${missingGlCodeLines.size} GL codes not found: ${[...missingGlCodeLines.keys()].join(', ')}`
})
}

This is an all-or-nothing gate. The pipeline doesn't import 9,950 valid rows and skip 50 bad ones — that would leave your ledger in a partial state. It validates everything first, then processes everything. Fail before you begin, not while you're halfway through.

Step 7: Group and Transform

CSV rows are flat. Journal entries are compound documents with a header and multiple line items. The grouping step bridges that gap:

const groupedTransactions = naoUtils._.groupBy(
allRows, (row: any) => cleanValue(row['Trans #'])
)

Each group becomes one journal entry. Within the group, every row is transformed into a typed journal item line. This is where raw strings become domain objects:

// -->Parse: amounts — strip US thousands commas then convert to number
const debitAmount = naoUtils._.toNumber(
(debitRaw || '0').replace(/,/g, '')
) || 0
const creditAmount = naoUtils._.toNumber(
(creditRaw || '0').replace(/,/g, '')
) || 0

Dates get the same treatment — string to DateTime object via naoDateTime(), never new Date():

journalEntryDate: naoDateTime(date).toJSDate(),
accountingDate: naoDateTime(date).toJSDate(),

Every field is explicitly converted to its target type. Strings become numbers. Date strings become Date objects. GL codes become document references via lookup. The CSV column Class becomes a warehouseId through a resolution function. Nothing arrives at the database in its original CSV form.

Step 8: Write — The Easy Part

After all that preparation, the actual write is anticlimactic:

await bob.executeRequestOrThrow(
eventOptions.createJournalEntryWithItemsActionCfpQuery,
payload,
{ skipSession: true, skipEvent: true }
)
countCreated++

One call per journal entry. The skipSession: true flag is deliberate — each entry auto-commits independently, avoiding MongoDB's 60-second transaction timeout on long-running imports. The skipEvent: true prevents downstream event cascades during bulk import.

Notice what's not here: no validation, no deduplication, no type conversion. All of that was handled in steps 1 through 7. The write trusts the pipeline above it.

The Preferred Alternative: flowBulk

The executeRequestOrThrow approach works here because each journal entry is a compound document — a header with nested line items — and the downstream hook (create-data-entry-for-journal-entry) handles that compound creation logic. We're delegating to a hook that knows how to build both the parent and child documents atomically.

But for simpler, flat document imports — GL accounts, contacts, products, warehouses — flowBulk is the preferred write strategy. It batches all operations into a single database round-trip, which is dramatically faster than sequential per-document calls.

Here's what the write step looks like with flowBulk:

// -->Get: the flow collection
const flowCollection = bob.flowGlobal.getFlowCollection(eventOptions.glNaoQueryOptions)

// -->Init: bulk orders
const flowBulk = flowCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.glNaoQueryOptions)
.bulkOrders()

// -->Build: bulk insert operations from prepared payloads
const flowQuery = flowCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.glNaoQueryOptions)

for (const account of preparedAccounts) {
const flowData = flowQuery.flowData().setData(account)
flowBulk.insertOne(flowData)
}

// -->Execute: all operations in a single batch
if (flowBulk.length > 0) {
data = await flowCollection.docs.runBulk(
bob.flowUser, { flowBulk }, bob.dbSession()
)
}

The performance difference is significant. Importing 10,000 GL accounts with executeRequestOrThrow in a loop takes ~500 seconds (50ms × 10,000). The same import with flowBulk takes ~2 seconds — one database round-trip for the entire batch.

When to Use Which

StrategyUse WhenTrade-off
executeRequestOrThrowCompound documents (header + lines), or when the write triggers business logic in a downstream hookSlower — one network round-trip per document, but each entry is independently committed and validated by the target hook
flowBulkFlat document imports (accounts, contacts, products) where you're writing directly to a collectionFaster — single batch operation, but you own all validation and no downstream hooks fire

The journal entry import in this article uses executeRequestOrThrow because the create-data-entry-for-journal-entry hook does more than just insert — it creates the journal header, generates line items, calculates balances, and links to fiscal periods. That logic already exists and is tested. Duplicating it inside the import hook would be a mistake.

Use flowBulk when you can. Fall back to per-entry calls when the write involves business logic you shouldn't be reimplementing.

Step 9: Monitor and Report

The hook tracks every outcome:

let countCreated = 0
let countSkipped = 0
let countDuplicate = 0
const skippedRows: { transNum: string; reason: string }[] = []

Memory usage is monitored every 30 seconds during long imports:

const memoryInterval = setInterval(() => {
const mem = process.memoryUsage()
naoLogger.log(
`Memory — heap: ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`
)
}, 30_000)

Progress is logged every 500 transactions. And the final result gives you the full picture:

data = { countCreated, countSkipped, countDuplicate, skippedRows }

No silent failures. No "import complete" with no details. Every row is accounted for.

The Pipeline at a Glance

StepPurposeWrites to DB?
1. Validate requestReject malformed callsNo
2. Load reference dataGL accounts, warehousesNo (reads)
3. Ensure preconditionsFiscal periods existConditional
4. Build dedup indexFetch existing entriesNo (reads)
5. Parse and cleanCSV → clean rowsNo
6. Preflight validateAll-or-nothing GL checkNo
7. Group and transformRows → journal payloadsNo
8. WriteCreate journal entriesYes
9. ReportCounters, skipped rowsNo

Eight steps of preparation. One step of writing. That ratio is not an accident — it's the architecture.

Hard-Won Lessons From Production Imports

The pipeline above works. But getting it to that state involved months of production imports, failed runs, angry rollbacks, and late-night debugging sessions. These are the lessons that don't show up in the code — they show up in the decisions behind it.

1. AI Doesn't Know Your Domain — And It Will Guess Confidently

Data imports are soaked in domain knowledge. What does "Class" mean in this CSV? It's a warehouse. What's a Trans #? It's a grouping key for compound journal entries. What does a zero-amount line mean — is it an error, or is it a tax-exempt placeholder?

AI will generate a perfectly structured import hook. It will parse, loop, and write. But it will consistently get the meaning wrong because it doesn't know your chart of accounts, your fiscal calendar, your warehouse naming conventions, or what "posted" means in your system versus the source system.

Every column in the CSV carries implicit business context that the AI has never seen. The GL Code column isn't just a string — it's a foreign key into your chart of accounts, and the mapping between the source system's codes and yours might not be 1:1. The Date column isn't just a date — it determines which fiscal period the entry lands in, which affects financial reporting.

Expect to rewrite 50-70% of the AI's field mapping and business logic. Use the AI for the pipeline structure — the download, parse, loop, write skeleton. Do the domain mapping yourself.

2. Data Preparation Should Be 70% of the Work

If you're spending most of your time on the database write, you're building the pipeline backward. The write is trivial once the data is clean. The hard part — the part that takes 70% of your development time — is everything before it:

  • Understanding the source file format (delimiters, encoding, quoting)
  • Identifying and filtering junk rows (subtotals, headers, blank lines)
  • Mapping source columns to target fields
  • Converting types (string amounts with commas → numbers, date strings → Date objects)
  • Resolving foreign keys (GL code → generalLedgerAccountId, Class → warehouseId)
  • Grouping flat rows into compound documents

If your commit history shows three days on data prep and an afternoon on the write, you're doing it right. If it's the inverse, your pipeline will break on the first real file.

3. The Error Rate Must Be Zero

Not "low." Not "acceptable." Zero.

Financial data imports don't have a margin for error. If you import 10,000 journal entries and 3 are wrong, you don't have 99.97% accuracy — you have a corrupted ledger. The accountant won't find those 3 bad entries. They'll find them in six months during an audit, and by then the cascade of dependent transactions makes the fix exponentially harder.

This is why the pipeline uses an all-or-nothing preflight gate. If a single GL code is invalid, the entire import aborts. It's aggressive, and users sometimes push back — "just skip the bad ones" — but partial imports create partial ledgers, and partial ledgers create real financial risk.

Design your pipeline so it either completes perfectly or doesn't start at all.

4. Prepare the Workspace for Fast Import-Wipe Cycles

You will run the import multiple times before it's right. The first run will reveal data issues you didn't anticipate. The second run will expose edge cases in your transformation logic. The third run might actually work.

This means you need the ability to wipe and reimport quickly:

  • Tag all imported documents with a recognizable prefix (PIPE- in this hook) so you can query and delete them in bulk
  • Use skipSession: true so each document auto-commits — if you need to kill a run midway, you know exactly what was written
  • Keep the source file immutable — download it once, process it from local storage, never modify the original
  • Have a cleanup script ready that deletes all PIPE-* journal entries so you can re-run from scratch

The import-wipe-reimport cycle is your development loop. Make it fast. If a wipe takes 20 minutes, you'll only test three iterations in an hour. If it takes 30 seconds, you'll test twenty.

5. The Audit Trail Checklist

Before writing the first line of code, answer these questions:

  • Naming convention: How will imported documents be identified? (PIPE-{transNum}, IMPORT-{batchId}, etc.)
  • Deduplication strategy: What makes two entries "the same"? Name? Source ID? Content hash?
  • Audit fields: Does each imported document carry its source file reference, import timestamp, and batch ID?
  • Transaction grouping: Are compound documents (header + lines) created atomically or individually?
  • Batching strategy: One DB call per document, or batched inserts? Does the downstream hook support batch payloads?
  • Session management: Shared transaction across all writes, or independent auto-commits? (For long imports, independent commits avoid MongoDB's 60-second transaction timeout)
  • Event suppression: Should downstream events (notifications, recalculations) fire during import, or be suppressed with skipEvent: true?
  • Rollback plan: If the import is wrong, how do you delete everything it created?
  • Completion guarantee: If the process crashes at row 5,000 of 10,000, can you resume or must you wipe and restart?

Answer every one of these before you start coding. They shape the pipeline's architecture in ways that are expensive to change later.

6. Measure Every Prep Query — You'll Be Surprised

Every reference data query in the prep stage loads data into memory. You need to know how much. The instinct is to worry about "200,000 documents" — that sounds like a lot. But if you're only projecting the name field, 200,000 short strings might be 2MB. That's nothing.

On the other hand, fetching 10,000 full documents with nested arrays of line items could be 500MB.

Measure, don't guess:

const result = await fc.docs.flowQuery()
.projection({ 'data.name': 1 })
.limit(2_000_000)
.getMany()

// Log the actual size
const sizeInBytes = Buffer.byteLength(JSON.stringify(result.data))
naoLogger.log(`Dedup index: ${result.data.length} docs, ${(sizeInBytes / 1024 / 1024).toFixed(1)}MB`)

This changes your architecture decisions. If the dedup index is 2MB, load it all into a Set — fast and safe. If it's 200MB, you need a different strategy: streaming comparison, database-side dedup, or chunked lookups.

The projection operator is your best friend here. The difference between { 'data.name': 1 } and fetching full documents can be 100x in memory footprint. Always project to the minimum fields you need, and always log the actual size.

7. There Is No Room for Error — The Import Must Complete

A 50% imported ledger is worse than a 0% imported ledger. With zero imports, the customer knows nothing happened. With half the data imported, they don't know which half — and they can't trust any of it.

This has design implications:

  • Preflight everything: Validate the entire file before writing the first document. If row 9,999 has an invalid GL code, you want to know before row 1 is committed.
  • Idempotent design: If the process crashes and restarts, it should skip already-imported entries (the dedup Set) and continue — not create duplicates.
  • Progress logging: Log every 500 transactions so you can see exactly where a failure occurred. The memory monitor isn't vanity — it's your early warning system for OOM kills.
  • Independent commits: skipSession: true means each journal entry is its own commit. If the process dies at entry 5,001, entries 1–5,000 are safe and the restart picks up at 5,001.

Design for the crash. It will happen. The question is whether you recover in minutes or spend a weekend rebuilding the ledger.

8. The Validation Report Is Your Customer Deliverable

Here's a pattern that saves weeks of back-and-forth: run the pipeline in validation-only mode first.

The preflight validation step — the one that checks GL codes, resolves warehouses, and verifies fiscal periods — produces a report that is exactly what the customer needs to clean their data. Don't just throw an error. Format the validation results as a deliverable:

Import Validation Report
========================
File: general-ledger-export-2024.csv
Total rows: 12,847
Valid rows: 12,691
Skipped (no Trans #): 142
Invalid GL codes: 3
- "4510" → found on 8 rows (lines 234, 567, 891, ...)
- "6200-A" → found on 2 rows (lines 1002, 1003)
- "MISC" → found on 4 rows (lines 5501, 5502, 5503, 5504)

Action required: Add missing GL codes to chart of accounts, or update the CSV to use valid codes.

Send this to the customer before running the actual import. Nine times out of ten, this report is all they need. They fix the three GL codes in their spreadsheet, re-export, and the next run succeeds cleanly.

The validation stage isn't just a safety gate — it's a communication tool. It speaks the customer's language (GL codes, line numbers) instead of yours (stack traces, error IDs).

9. Always Benchmark at Scale — What If This File Has 10 Million Entries?

Your test file has 500 rows and runs in 3 seconds. Your production file has 200,000 rows. Your largest customer's file has 10 million. Does your pipeline survive?

Things that break at scale:

  • Memory: Loading 10 million rows into a JavaScript array. At ~1KB per parsed row object, that's 10GB. Your 8GB server is dead.
  • Dedup index: A Set of 2 million strings is fine (~150MB). A Set of 50 million strings is not.
  • Sequential writes: 10 million executeRequestOrThrow calls at 50ms each = 139 hours. You need batching or parallelism.
  • Logging: Logging every skipped row with its reason — if 2 million rows are skipped, your skippedRows array consumes more memory than the actual data.
  • Progress intervals: Logging every 500 transactions makes sense at 10,000. At 10 million, that's 20,000 log lines — still manageable, but worth verifying.

Run the numbers before you run the import:

Metric500 rows200K rows10M rows
Raw file size~50KB~20MB~1GB
Parse time< 1s~5s~60s
Memory (parsed)~500KB~200MB~10GB ❌
Dedup queryinstant~2s~30s
Sequential writes (50ms/ea)25s2.7hrs139hrs ❌

If the 10M column has red marks, your architecture needs to change — streaming parsers, chunked processing, parallel writes, or database-side dedup. Don't discover this in production.

The file you're testing with is never the file you'll receive.

The Takeaway

The instinct is to start with the database write and work backward. Production import pipelines work the other way: start with the source data and build forward through layers of validation, cleaning, and transformation until the write becomes a formality.

Data preparation is 70% of the work. The error rate must be zero. The AI will get the structure right and the domain wrong. Your validation report is your customer's favorite deliverable. And the file you tested with is never the file you'll receive in production.

Your CSV is not your data. It's raw material. The pipeline is the refinery.

Trust the data only after you've earned that trust — one step at a time.

Building an AI-Ready Codebase: Architecture Decisions That Pay Off

· 5 min read
Gabriel Paunescu
Founder CTO Neologic

Your architecture is your AI strategy. Every decorator, every naming convention, every file-per-function decision determines whether AI can contribute meaningfully to your codebase — or just generate noise.

The Premise

Most teams try to adopt AI coding assistants by writing better prompts. Logic Bee took a different approach: redesign the architecture so AI can't get it wrong. One hook per file. Mandatory decorators with machine-readable metadata. Self-registering modules. The result? AI agents that understand the codebase as well as a new team member — on day one.

The Story

When Logic Bee was a monolithic Express app, AI code generation was unreliable. Functions were tangled across large files. Business logic mixed with routing. Naming was inconsistent. An AI asked to "add a billing function" would generate code that worked in isolation but broke in context.

The refactoring to a decorator-based, one-hook-per-file architecture wasn't about AI. It was about maintainability, testability, and developer experience. But the side effect was transformative: the same codebase that became easier for humans to navigate also became trivially parseable by AI.

The Five Decisions

1. One Function, One File

Every hook lives in its own file at a predictable path:

hooks/{library-slug}/{method-slug}/{method-slug}.{library-slug}.hook.ts

Why it matters for AI: When the AI generates a new hook, it never needs to decide where to put the code. The path is deterministic from the library and method names. There's no risk of the AI inserting code into the wrong file or appending to an existing module.

Why it matters for humans: Code reviews are self-contained. git diff shows one function per file. Merge conflicts between developers working on different hooks are eliminated.

2. Self-Describing Decorators

Every hook wears its identity on its sleeve:

@LogicHook({
name: 'FinanceBillsCalculateLateFees',
path: 'finance-bills/calculate-late-fees',
library: 'finance-bills',
method: 'calculate-late-fees'
})

Why it matters for AI: The decorator is structured metadata — not a comment, not documentation, but code that the system reads at boot time. AI can scan decorators to understand what a hook does without reading the business logic.

Why it matters for humans: New developers can grep for any hook by name, library, or method. Discovery is instant.

3. Auto-Generated Barrels

When a new hook is created, barrel files (index.ts) are regenerated automatically. Developers never manually import hooks.

Why it matters for AI: The AI can scaffold a hook, run the barrel generator, and know that the hook is registered — without understanding the module system. No manual import means no forgotten imports.

Why it matters for humans: One less thing to remember. One less source of "it compiles but doesn't run" bugs.

4. Standardized Wrapper Pattern

Every hook uses the Bob Wrapper: execute()async (bob) =>try/catchreturnEventResult.

public static execute() {
return async (bob: BobRequest<T>) => {
let ok = true, error: any = null, data
try {
// business logic
} catch (err) { error = err; ok = false }
return returnEventResult(bob, { ok, error, data })
}
}

Why it matters for AI: The wrapper is a rigid template. AI fills in the business logic between START_CODE_LOGIC and END_CODE_LOGIC. The structural code around it is identical across hundreds of hooks — which means the AI never gets it wrong.

Why it matters for humans: Every hook reads the same way. Error handling is guaranteed. The cognitive load of reading unfamiliar code drops to near zero.

5. Machine-Readable Skills

Business rules, conventions, and patterns are encoded in .agents/skills/ — not in wiki pages, Slack threads, or tribal knowledge.

Why it matters for AI: Skills are loaded at prompt time. The AI doesn't need to infer conventions from code samples — it reads the rules directly.

Why it matters for humans: Skills are versioned with the codebase. When conventions change, the skill files update, and the AI immediately adapts.

Measuring the Impact

Before the architecture refactoring:

MetricMonolith EraHook Architecture
AI-generated code acceptance rate~30%~85%
Time to onboard new developer2–3 weeks2–3 days
Average code review time per function25 min8 min
Merge conflicts per sprint8–120–2
Lines of code per business function200–50040–120

What Other Teams Can Learn

You don't need to use Logic Bee's exact architecture to make your codebase AI-ready. But these principles apply universally:

Principle 1: Predictable File Locations

If your AI has to guess where a file goes, it will guess wrong 30% of the time. A convention that maps names to paths eliminates this entirely.

Principle 2: Structured Metadata Over Comments

Comments are for humans. Decorators (or annotations, or frontmatter) are for machines AND humans. Use structured metadata for anything the AI needs to discover.

Principle 3: One Unit of Logic Per File

The smaller the blast radius of a change, the safer AI-generated code becomes. A wrong line in a 40-line file is easy to catch. A wrong line in a 400-line file is not.

Principle 4: Rigid Structural Templates

The more of your code that's identical across instances, the less the AI can get wrong. Boilerplate isn't the enemy — inconsistent boilerplate is.

Principle 5: Encode Conventions, Don't Assume Them

If a convention lives only in your head, the AI doesn't know it. If it lives in a skill file, the AI follows it automatically.

The Takeaway

AI-ready architecture isn't a separate initiative. It's good architecture: modular, predictable, self-describing, and convention-driven. The same decisions that make your codebase maintainable for a team of ten developers make it navigable for AI agents.

Design for the junior developer who just joined your team. The AI is that developer — permanently.

When to Override the AI: Patterns the Machine Gets Wrong

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

AI coding assistants are remarkably good at following patterns. They're remarkably bad at knowing when a pattern doesn't apply. Here are three categories where you should always take the wheel.

The Premise

AI generates code by pattern matching against its training data and your project's skill files. For 80% of Logic Bee hooks, this produces correct, convention-following code. But certain categories of logic require reasoning that current AI models consistently get wrong — not because they're broken, but because the problems require domain knowledge that never appears in code patterns alone.

The Story

A senior developer tracked every AI-generated hook over three months and categorized the corrections. Three patterns emerged: financial calculations, multi-tenant edge cases, and transaction session management. Each required the same type of intervention — the developer understanding why, not just how.

Pattern 1: Financial Calculations

What the AI Does

AI uses JavaScript arithmetic because it looks correct:

// ❌ AI-generated: floating point disaster
const taxAmount = subtotal * taxRate
const total = subtotal + taxAmount + shippingCost

The problem isn't obvious until you see: 199.99 * 0.08 = 15.999200000000001.

What the Developer Does

// ✅ mathChain for precision
const taxAmount = naoUtils.mathChain(subtotal).multiply(taxRate).done()
const total = naoUtils.mathChain(subtotal)
.add(taxAmount)
.add(shippingCost)
.done()

Why AI Gets This Wrong

AI sees * and + operators in countless training examples. mathChain() is Logic Bee-specific. Even with skill files telling the AI to use mathChain, it sometimes falls back to raw operators for "simple" calculations — but there's no such thing as a simple financial calculation.

Rule: Any arithmetic involving currency → naoUtils.mathChain(). No exceptions.

Pattern 2: Multi-Tenant Edge Cases

What the AI Does

AI handles the happy path correctly — querying documents with .user(bob.flowUser). But it fails on edge cases where tenant boundaries interact in unexpected ways:

// ❌ AI-generated: cross-tenant data leak in batch processing
const allVendors = await vendorCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.vendorNaoQueryOptions)
.query({ 'data.type': 'preferred' })
.getMany(undefined, 'PurchasingInterface.Vendor')

// AI then uses vendor IDs to query another collection
// But what if a vendor was shared across tenants?
const vendorBills = await billCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.billNaoQueryOptions)
.query({ 'data.vendorId': { $in: allVendors.map(v => v.docId) } })
.getMany(undefined, 'FinanceInterface.Bill')

What the Developer Does

The developer adds business-unit scoping:

// ✅ Add explicit business unit filtering
const vendorBills = await billCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.billNaoQueryOptions)
.query({
'data.vendorId': { $in: allVendors.map(v => v.docId) },
'data.businessUnitId': bob.flowUser.getBusinessUnitId() // explicit scope
})
.getMany(undefined, 'FinanceInterface.Bill')

Why AI Gets This Wrong

Multi-tenancy is an architectural concern, not a code pattern. The AI knows to add .user(bob.flowUser) because the skill file says so. But it doesn't understand why — that in a shared-vendor scenario, FlowQuery's default scoping may not be sufficient and additional business-unit filtering is needed.

Rule: Any cross-collection join → verify tenant and business-unit boundaries manually.

Pattern 3: Complex Transaction Sessions

What the AI Does

AI includes bob.dbSession() on write operations because the skill file says so. But it doesn't understand transaction design:

// ❌ AI-generated: partial transaction safety
try {
// Operation 1: create credit memo
await creditMemoCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.creditMemoNaoQueryOptions)
.insertOne(creditMemoDoc, bob.dbSession())

// Operation 2: update invoice status
await invoiceCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.invoiceNaoQueryOptions)
.docId(invoiceId)
.updateOne({ 'data.status': 'credited' }, bob.dbSession())

// Operation 3: adjust vendor balance (NO SESSION!)
await vendorCollection.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.vendorNaoQueryOptions)
.docId(vendorId)
.updateOne({ 'data.balance': newBalance }) // ← missing bob.dbSession()
}

What the Developer Does

The developer ensures all writes within a logical transaction share the session — and adds rollback awareness:

// ✅ All writes in the same session, with failure awareness
const session = bob.dbSession()

await creditMemoCollection.docs.flowQuery()...insertOne(creditMemoDoc, session)
await invoiceCollection.docs.flowQuery()...updateOne({ 'data.status': 'credited' }, session)
await vendorCollection.docs.flowQuery()...updateOne({ 'data.balance': newBalance }, session)

Why AI Gets This Wrong

The AI treats bob.dbSession() as a "should include" parameter, not a "must include for atomicity" requirement. It doesn't reason about what happens when operation 3 fails — operation 1 and 2 commit, but the vendor balance is stale.

Rule: If you're touching more than one collection → every write operation must use bob.dbSession(). Audit this manually every time.

The Decision Framework

SituationAI or Developer?
Hook scaffolding and boilerplate✅ AI
Single-collection CRUD operations✅ AI
Standard FlowQuery chains✅ AI (with review)
Financial arithmetic❌ Developer
Cross-collection joins with tenant concerns❌ Developer
Multi-document transaction design❌ Developer
Error message wording❌ Developer
Schema-specific validation rules❌ Developer
Performance optimization (batching, indexing)❌ Developer

The Takeaway

The AI is your fastest pair programmer — for patterns it has seen before. For patterns that require understanding, not just matching, the developer must intervene.

Three questions to ask about every AI-generated hook:

  1. Is there money involved? → Verify all math uses mathChain()
  2. Are there cross-collection queries? → Verify tenant and business-unit scoping
  3. Are there multiple writes? → Verify every one uses bob.dbSession()

Know when to let the AI drive. Know when to take the wheel.

AI-Assisted Data Import Pipelines: A Real-World Case Study

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

Importing 10,000 general ledger accounts shouldn't require 10,000 lines of code. Here's how we built a complete data import pipeline with AI — and the six places where the developer had to step in.

The Premise

Data import is one of ERP's most common, tedious, and error-prone workflows. Parse a file, validate rows, map fields, handle duplicates, write to the database, and report errors — all with transaction safety and tenant isolation. It's the perfect candidate for AI generation because the structure is repetitive, but the business logic is unique per import type.

The Story

The team needed to import general ledger (GL) accounts from a client's CSV export. The import pipeline needed to:

  1. Parse a CSV file with 10,000+ rows
  2. Validate each row against a GL account schema
  3. Skip duplicate account numbers
  4. Create new accounts in the finance/general-ledger collection
  5. Report successes, skips, and failures in a structured response

A developer prompted the AI to build the entire pipeline. The AI generated 90% of the working code. But six critical interventions turned it from "mostly working" into "production-ready."

The Pipeline Architecture

CSV Upload → Parse → Validate → Deduplicate → Insert → Report

Each step maps to a section of the hook:

@LogicHook({
name: 'ImportPipeGeneralLedgerAccounts',
path: 'import-pipe/general-ledger-accounts',
library: 'import-pipe',
method: 'general-ledger-accounts',
legacy: 'app/app.importPipeHooks.generalLedgerAccounts'
})

Where the Developer Stepped In

Intervention 1: CSV Parsing Edge Cases

The AI used a basic split-by-comma approach. Real CSV files have quoted fields, embedded commas, and encoding issues.

// ❌ AI-generated: naive parsing
const rows = csvContent.split('\n').map(line => line.split(','))

// ✅ Developer fix: use the project's CSV utility
const rows = naoUtils.csv.parse(csvContent, {
header: true,
skipEmptyLines: true,
dynamicTyping: false // keep everything as strings for validation
})

Intervention 2: Batch Size for Database Operations

The AI generated a loop that inserted one document at a time — 10,000 individual insert operations.

// ❌ AI approach: one-by-one inserts
for (const account of validAccounts) {
await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.glNaoQueryOptions)
.insertOne(account, bob.dbSession())
}

// ✅ Developer fix: batch inserts
const BATCH_SIZE = 500
for (let i = 0; i < validAccounts.length; i += BATCH_SIZE) {
const batch = validAccounts.slice(i, i + BATCH_SIZE)
await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.glNaoQueryOptions)
.insertMany(batch, bob.dbSession())
}

Intervention 3: Duplicate Detection Strategy

The AI checked for duplicates by querying each account number individually. The developer replaced it with a single bulk query:

// ✅ Fetch all existing account numbers in one query
const existing = await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.glNaoQueryOptions)
.query({ 'data.accountNumber': { $in: incomingAccountNumbers } })
.project({ 'data.accountNumber': 1 })
.getMany(undefined, 'FinanceInterface.GeneralLedgerAccount')

const existingSet = new Set(existing.map(e => e.data.accountNumber))
const newAccounts = validAccounts.filter(a => !existingSet.has(a.data.accountNumber))

Intervention 4: Validation Schema

The AI generated a validation schema that was too permissive — accepting any string for account type. The developer added enum constraints:

// ✅ Strict validation
const accountSchema = SuperJoi.object({
accountNumber: SuperJoi.string().pattern(/^\d{4,6}$/).required(),
accountName: SuperJoi.string().max(200).required(),
accountType: SuperJoi.string().valid(
'asset', 'liability', 'equity', 'revenue', 'expense'
).required(),
parentAccount: SuperJoi.string().allow(null, ''),
isActive: SuperJoi.boolean().default(true)
})

Intervention 5: Error Reporting Granularity

The AI returned a generic "X succeeded, Y failed" message. The developer added row-level error reporting:

// ✅ Row-level reporting
const report = {
total: rows.length,
created: newAccounts.length,
skipped: existingSet.size,
errors: validationErrors.map(e => ({
row: e.rowNumber,
accountNumber: e.data?.accountNumber || 'unknown',
reason: e.error.message
}))
}

Intervention 6: Memory Management

With 10,000+ rows, the AI was holding all raw data, parsed data, and results in memory simultaneously. The developer added streaming-style processing:

// ✅ Process in chunks, release references
let processedCount = 0
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const chunk = rows.slice(i, i + BATCH_SIZE)
const { valid, errors } = validateChunk(chunk, i)
validationErrors.push(...errors)
const newInChunk = valid.filter(a => !existingSet.has(a.data.accountNumber))
if (newInChunk.length > 0) {
await insertBatch(fc, bob, newInChunk, eventOptions)
processedCount += newInChunk.length
}
// chunk goes out of scope, eligible for GC
}

The Final Result

MetricAI-GeneratedAfter Intervention
Parse strategySplit by commanaoUtils.csv.parse()
Insert strategyOne-by-one (10,000 ops)Batch of 500 (20 ops)
Duplicate checkIndividual queriesSingle bulk query
ValidationPermissive schemaStrict enums + patterns
Error reportingSummary onlyRow-level details
Memory usageAll-in-memoryChunked processing

The Takeaway

AI generates excellent pipeline structure. The developer adds:

  1. Domain-specific parsing — real-world files aren't clean
  2. Performance optimization — batch operations, bulk queries
  3. Strict validation — business rules the AI doesn't know
  4. Detailed reporting — users need row-level feedback
  5. Resource management — memory and connection awareness

The AI builds the highway. You paint the lane markings.

Skills as a Contract Between AI and Developer

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

Skills aren't documentation. They're executable contracts — structured knowledge files that tell the AI exactly how your codebase works. Invest two hours writing a skill file, save twenty hours of correcting AI output.

The Premise

Most teams treat AI assistants like search engines: ask a question, get an answer, fix what's wrong. But Logic Bee takes a different approach. Instead of hoping the AI figures out how the code works, we tell it — through .agents/skills/ files that serve as machine-readable contracts between the developer and the AI.

The Story

Before skill files, every AI prompt required extensive context: "By the way, we use naoUtils.mathChain() for math, and naoDateTime() for dates, and errors should use naoFormatErrorById(), and the first thing in the try block should be eventOptions..."

One developer spent 15 minutes adding context to every prompt. Then they extracted it into a skill file — once. Now every AI interaction in the codebase automatically follows those conventions without any prompt engineering.

The return on investment was immediate: fewer review cycles, fewer rejected PRs, and dramatically fewer convention violations in AI-generated code.

What a Skill File Looks Like

# SKILL.md — hook-code-style

---
name: hook-code-style
description: Coding rules, naming patterns, and structural conventions for Logic Bee hooks
---

## Hard Rules (violations = merge blockers)

1. `ok` and `data` must be assigned before `returnEventResult`
2. Every `.flowQuery()` must include `.user(bob.flowUser)`
3. Every write operation must pass `bob.dbSession()`
...

## Soft Conventions (violations = code review comments)

1. `eventOptions` is the first declaration in the try block
2. Use arrow comments: `// -->Get:`, `// -->Set:`
3. Use `naoUtils.mathChain()` for all arithmetic
...

The skill is just a markdown file. But it transforms AI behavior from "best guess" to "informed generation."

What Makes a Good Skill

1. Be Prescriptive, Not Descriptive

❌ "We usually try to use mathChain for calculations"
✅ "ALWAYS use naoUtils.mathChain() for arithmetic. Raw +, -, *, / operators
on financial values are a hard rule violation."

2. Include Examples

Every rule should have a ✅ correct and ❌ incorrect code example. AI learns better from contrast.

3. Separate Hard and Soft Rules

The distinction matters. Hard rules should block generation. Soft rules should trigger warnings.

4. Keep It Focused

One skill per concern. hook-code-style covers coding patterns. flow-query covers database access. Don't mix them.

5. Document the Edge Cases

The 80% cases are obvious. Skills earn their ROI on the 20% that trips up AI:

"When a hook calls bob.runAction(), ALWAYS check the .ok property of the
result before continuing. AI tends to assume sub-requests always succeed."

The Skill Ecosystem

Logic Bee maintains a curated set of skills:

SkillContract
creating-hooksFile structure, decorator metadata, scaffolding CLI
bob-request-apiBobRequest accessors and lifecycle
flow-queryDatabase operations and tenant scoping
hook-code-styleNaming, formatting, structural rules
hook-validation-rulesHard rules and soft conventions
event-routingHow events dispatch to hooks
connectorsExternal API integration patterns
interfacesType definitions and data factories

Each skill is a contract: "If you follow these rules, the generated code will be correct."

Measuring ROI

Before and after skill files:

MetricBefore SkillsAfter Skills
AI-generated hooks needing revision70%15%
Convention violations per hook3.2 avg0.4 avg
Time from prompt to merge-ready25 min8 min
Developer context in prompts5–10 lines1 line (skill reference)

Writing Your First Skill

Start here:

  1. Pick your biggest pain point — what does the AI consistently get wrong?
  2. Write the rule — prescriptive, with examples
  3. Save it to .agents/skills/your-skill-name/SKILL.md
  4. Test it — prompt the AI to use the skill and check the output
  5. Iterate — add rules as you find new patterns the AI misses

The Takeaway

Skills are the highest-leverage investment you can make in AI-assisted development. They turn tribal knowledge into machine-readable contracts. The developer's job isn't to correct AI output — it's to define the rules that make correction unnecessary.

Write the contract once. Get correct code forever.

Debugging AI-Generated Code in an Event-Driven System

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

The hook runs. It returns ok: true. The data looks right. But three hooks later in the pipeline, something breaks — and the stack trace points nowhere useful. Welcome to debugging in an event-driven system.

The Premise

Debugging AI-generated hooks is different from debugging hand-written code. The code looks correct — it follows every convention, passes validation, and compiles cleanly. But AI optimizes for pattern matching, not for understanding the execution context. When a bug hides in the space between hooks, traditional debugging falls apart.

The Story

A developer asks the AI to generate a hook for processing refunds. The hook queries the original invoice, calculates the refund amount, creates a credit memo, and updates the invoice status. The AI generates clean code. Tests pass. But in production, customers report that their invoice status shows "refunded" even when the credit memo creation fails.

The root cause? The AI wrote the status update before the credit memo creation — and without a shared database session, the two operations weren't atomic.

The Debugging Playbook

Step 1: Trace the Event Route

Every request in Logic Bee flows through: HTTP → Auth → FlowUser → Event Engine → Hook(s) → Response. When something goes wrong, first verify that the right hook is even being called:

// Add temporary logging at the top of the try block
console.log(`[DEBUG] ${new Date().toISOString()} - Hook entered`, {
hookPath: 'finance-bills/process-refund',
docId: bob.dataPayload?.docId,
userId: bob.flowUser?.getUserInfo()?.id
})

If you don't see this log, the event routing is wrong — check the @LogicHook decorator's path property.

Step 2: Verify the BobRequest Lifecycle

The bob object carries context through the entire execution. AI-generated code sometimes breaks the lifecycle by:

  • Modifying bob.dataPayload directly instead of using bob.replaceDataPayload()
  • Not passing bob.dbSession() to write operations, breaking transaction boundaries
  • Calling bob.executeRequestOrThrow() without error checking

Check the bob state at each step:

// -->Debug: log bob state before critical operations
console.log('[DEBUG] dataPayload:', JSON.stringify(bob.dataPayload, null, 2))
console.log('[DEBUG] dbSession exists:', !!bob.dbSession())

Step 3: Check Operation Order

AI generates operations in the order they appear in the prompt, which may not be the correct execution order. In the refund story, the fix was:

// ❌ AI-generated order (status before credit memo)
await updateInvoiceStatus(bob, 'refunded')
await createCreditMemo(bob, refundData)

// ✅ Correct order (credit memo first, then status)
const creditMemo = await createCreditMemo(bob, refundData)
if (creditMemo) {
await updateInvoiceStatus(bob, 'refunded')
}

Step 4: Verify Transaction Boundaries

When a hook performs multiple writes, all should share the same database session:

// ❌ AI sometimes creates independent write operations
await collection1.docs.flowQuery()...updateOne({ status: 'refunded' })
await collection2.docs.flowQuery()...insertOne(creditMemoDoc)

// ✅ Both must use bob.dbSession()
await collection1.docs.flowQuery()...updateOne({ status: 'refunded' }, bob.dbSession())
await collection2.docs.flowQuery()...insertOne(creditMemoDoc, bob.dbSession())

Step 5: Check Sub-Request Error Propagation

When hooks call other hooks via bob.executeRequestOrThrow() or bob.runAction(), AI sometimes ignores the returned error:

// ❌ AI ignores sub-request failure
const result = await bob.runAction('inventory.adjust-stock', { data: adjustmentData })
// continues execution even if result.ok === false

// ✅ Check the result
const result = await bob.runAction('inventory.adjust-stock', { data: adjustmentData })
if (!result.ok) {
throw result.error || naoFormatErrorById('bad_request', {
reason: 'Stock adjustment failed during refund processing'
})
}

Common AI-Generated Bug Patterns

PatternSymptomRoot Cause
Silent data corruptionDownstream hooks get wrong dataDirect bob.dataPayload mutation
Partial writesSome documents updated, others notMissing bob.dbSession() on some writes
Wrong hook calledAction does nothing or wrong thingDecorator path doesn't match expected route
Ignored sub-request errorsHappy path always succeedsMissing .ok check on runAction results
Race conditionsIntermittent failures under loadawait missing on async calls

The Takeaway

When debugging AI-generated hooks in a pipeline:

  1. Start at the route — is the right hook being called?
  2. Check the bob lifecycle — is the request context intact?
  3. Verify operation order — does the sequence match the business requirement?
  4. Audit every write — does each one use bob.dbSession()?
  5. Test sub-requests — are errors from child hooks handled?

The AI writes code that looks right. Your job is to verify it runs right — in context, in sequence, and under failure.

The Developer's Review Checklist for AI-Generated Hooks

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

AI follows the rules it knows. Your job is knowing the rules it missed. Here's the definitive review checklist for AI-generated Logic Bee hooks — 10 hard rules and 12 soft conventions, explained with real examples.

The Premise

Logic Bee's AI validation system checks generated hooks against two categories of rules: hard rules that must never be violated, and soft conventions that should be followed unless there's a documented reason not to. The AI self-validates, but no validation is perfect — and understanding why each rule exists makes you a better reviewer.

The Story

A junior developer merges an AI-generated hook without review. It passes all automated checks. Two weeks later, a production incident reveals that the hook was silently mutating bob.dataPayload directly — causing downstream hooks in the pipeline to receive corrupted data. The mutation wasn't caught because it's a soft convention violation, not a hard rule.

That incident inspired this checklist.

The 10 Hard Rules

These are non-negotiable. If any of these fail, the hook must not be merged.

1. ok Must Be Assigned Before Return

// ❌ Missing ok assignment in success path
try {
data = await someOperation()
// forgot: ok = true
} catch (err) { error = err; ok = false }

2. data Must Be Assigned Before Return

Every successful execution must set data — even if it's just { success: true }.

3. bob.flowUser Must Be Passed to Every FlowQuery

No exceptions. No helper functions that skip it. Every .flowQuery() chain needs .user(bob.flowUser).

4. returnEventResult Must Be the Last Statement

// ❌ Code after returnEventResult
return returnEventResult(bob, { ok, error, data })
console.log('done') // unreachable but indicates confusion

5. Error Variable Must Be Set in Catch Block

// ❌ Missing error assignment
catch (err) {
ok = false
// forgot: error = err
}

6. try/catch Must Wrap All Logic

No business logic outside the try block except variable initialization.

7. FlowQuery Must Include .flowOptions()

Every query needs the collection metadata to resolve correctly.

8. Decorator Metadata Must Match File Path

path: 'finance-bills/calculate-late-fees' must correspond to the actual file location.

9. Write Operations Must Pass bob.dbSession()

For transactional integrity across multi-document updates.

10. Thrown Errors Must Use naoFormatErrorById

Raw throw new Error() produces unstructured error responses.

The 12 Soft Conventions

These improve code quality and consistency. Violations should be fixed unless there's a strong reason to deviate.

1. eventOptions First

Declare the eventOptions object as the first statement inside the try block.

try {
const eventOptions = {
billNaoQueryOptions: { docName: 'bill', cfpPath: 'finance/bills' }
}
// ... rest of logic
}

2. Arrow Comments for Structure

Use // -->Get:, // -->Set:, // -->Validate:, // -->Iterate:, // -->Update: prefix comments.

3. naoUtils.mathChain() for Math

Never use raw arithmetic for financial calculations. Floating point errors are real.

// ❌ Raw arithmetic
const total = quantity * unitPrice

// ✅ Math chain
const total = naoUtils.mathChain(quantity).multiply(unitPrice).done()

4. naoDateTime() for Dates

Never use new Date() or Date.now(). The Luxon-based wrapper handles timezones correctly.

5. Don't Mutate bob.dataPayload

Read from it. Don't write to it. Use bob.replaceDataPayload() if you need to pass modified data downstream.

6. One Responsibility Per Hook

If the AI generates a hook that both validates and processes, consider splitting it into two hooks.

7. Guard Clauses Before Logic

Check for nulls, empty arrays, and invalid states at the top of the try block.

8. Descriptive Error Reasons

// ❌ Vague
throw naoFormatErrorById('bad_request', { reason: 'Invalid' })

// ✅ Descriptive
throw naoFormatErrorById('bad_request', {
reason: `Bill ${bill.docId} has zero balance and cannot accrue late fees`
})

9. TypeScript Interface References

Pass the interface name to .getMany() and .getOne() for type safety.

10. Consistent Variable Naming

Use fc for flow collections, doc for single documents, docs for arrays.

11. No console.log in Production Code

Use the structured logging utilities from @logic-bee/utils.

12. End With ok = true; data = result

The last two lines of the try block should always be the success assignment.

The Review Shortcut

When reviewing AI-generated hooks, scan in this order:

  1. Scroll to the bottom — is returnEventResult the last statement?
  2. Search for .flowQuery() — does every chain have .user(bob.flowUser)?
  3. Search for .updateOne() / .updateMany() — does every write pass bob.dbSession()?
  4. Read the catch block — are both error = err and ok = false present?
  5. Read the first line of the try block — is eventOptions declared first?

If all five pass, you're likely looking at a clean hook.

The Takeaway

Print this. Pin it next to your monitor. Use it every time you review AI output:

Hard rules = merge blockers. Soft conventions = code quality. Both = production readiness.

AI-Powered Connector Integration: Wiring Up External APIs Faster

· 3 min read
Gabriel Paunescu
Founder CTO Neologic

Every Stripe integration starts the same way — auth headers, request builders, error mappers, retry logic. Let the AI handle the boilerplate so you can focus on the business logic that makes your integration unique.

The Premise

Logic Bee's connector architecture separates external API communication into reusable connector classes in libs/connectors/. When a hook needs to talk to Stripe, QuickBooks, or UPS, it calls a connector — never the raw HTTP client. This pattern is perfect for AI generation because connectors follow a rigid contract.

The developer's role shifts from writing HTTP calls to validating auth flows, error mapping, and retry behavior.

The Story

The team needs a new ShipStation integration for the shipping-management library. A developer prompts:

"Add a ShipStation connector that creates shipment labels and tracks packages. Wire it into a new hook called create-shipment-label in shipping-management. Use the connectors, creating-hooks, and bob-request-api skills."

The AI generates two things: the connector class and the hook that uses it.

What the AI Generates

The Connector

export class ShipStationConnector {
private baseUrl = 'https://ssapi.shipstation.com'

constructor(private apiKey: string, private apiSecret: string) {}

async createLabel(shipmentData: CreateLabelPayload): Promise<ShipStationLabel> {
const response = await fetch(`${this.baseUrl}/shipments/createlabel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${this.apiKey}:${this.apiSecret}`).toString('base64')}`
},
body: JSON.stringify(shipmentData)
})

if (!response.ok) {
throw naoFormatErrorById('bad_request', {
reason: `ShipStation API error: ${response.status}`
})
}

return response.json()
}
}

The Hook

@LogicHook({
name: 'ShippingManagementCreateShipmentLabel',
path: 'shipping-management/create-shipment-label',
library: 'shipping-management',
method: 'create-shipment-label',
legacy: 'app/app.shippingManagementHooks.createShipmentLabel'
})
export class ShippingManagementCreateShipmentLabel {
public static execute() {
return async (bob: BobRequest<{ data: { data: any; naoQueryOptions: NaoQueryOptions } }>) => {
let ok = true, error: any = null, data
try {
const eventOptions = {
shipmentQuery: { docName: 'shipment', cfpPath: 'shipping/shipments' }
}

// -->Get: shipment document
const shipment = await bob.flowGlobal
.getFlowCollection(eventOptions.shipmentQuery)
.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.shipmentQuery)
.docId(bob.dataPayload.docId)
.getOne()

if (!shipment) {
throw naoFormatErrorById('not_found', { reason: 'Shipment not found' })
}

// -->Create: shipping label via connector
const connector = new ShipStationConnector(
process.env.SHIPSTATION_API_KEY,
process.env.SHIPSTATION_API_SECRET
)
const label = await connector.createLabel({
orderId: shipment.data.orderId,
weight: shipment.data.totalWeight,
dimensions: shipment.data.dimensions,
shipTo: shipment.data.shippingAddress
})

// -->Update: shipment with tracking info
await bob.flowGlobal
.getFlowCollection(eventOptions.shipmentQuery)
.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.shipmentQuery)
.docId(shipment.docId)
.updateOne({
'data.trackingNumber': label.trackingNumber,
'data.labelUrl': label.labelUrl,
'data.status': 'label-created'
}, bob.dbSession())

ok = true
data = { docIds: [shipment.docId], trackingNumber: label.trackingNumber }
} catch (err) { error = err; ok = false }
return returnEventResult(bob, { ok, error, data })
}
}
}

What You Need to Verify

1. Credential Management

AI often hardcodes process.env.VARIABLE_NAME directly. In Logic Bee, credentials should flow through the configuration layer:

// ❌ AI default
const connector = new ShipStationConnector(
process.env.SHIPSTATION_API_KEY,
process.env.SHIPSTATION_API_SECRET
)

// ✅ Better: use workspace-scoped config
const config = bob.flowUser.getWorkspaceConfig('shipstation')
const connector = new ShipStationConnector(config.apiKey, config.apiSecret)

2. Error Response Mapping

The AI generates a generic error handler. Real APIs return structured errors — map them:

// ✅ Map API-specific errors to Logic Bee error format
if (!response.ok) {
const errorBody = await response.json()
throw naoFormatErrorById('bad_request', {
reason: `ShipStation: ${errorBody.ExceptionMessage || response.statusText}`,
details: errorBody
})
}

3. Rate Limiting and Retries

External APIs have rate limits. The AI rarely adds retry logic unless prompted.

4. Payload Validation

Verify the connector validates outbound payloads before sending — missing required fields should fail fast, not at the API level.

The Takeaway

The connector integration checklist:

  • Credentials loaded from workspace config, not process.env
  • API errors mapped to naoFormatErrorById with descriptive reasons
  • Rate limiting and retry logic included
  • Outbound payload validated before API call
  • Response types match TypeScript interfaces
  • API-specific edge cases documented in connector class

Let AI wire the plumbing. You verify the valves.

Prompt Engineering for ERP Logic: How to Talk to Your AI Agent

· 4 min read
Gabriel Paunescu
Founder CTO Neologic

The difference between a hook that needs 20 minutes of fixing and one that's merge-ready comes down to how you write the prompt. Garbage in, garbage out — but structured prompts unlock structured code.

The Premise

Logic Bee's AI agent has skills — structured knowledge files about the codebase's conventions, patterns, and tools. But skills are only activated when the AI recognizes them as relevant. A vague prompt activates vague knowledge. A precise prompt activates exactly the right skill at the right depth.

The Story

Two developers need the same hook: a function in the inventory-management library that adjusts stock levels after a sales order is confirmed. Here are their prompts:

The Vague Prompt

"Create a hook that updates inventory when an order is placed."

The AI generates a functional but generic hook. It uses a basic query pattern, doesn't reference the correct collection names, and invents a field structure that doesn't match the actual schema.

The Precise Prompt

"Create a new hook in inventory-management called adjust-stock-on-order-confirm. When a sales order status changes to 'confirmed', decrement the stock quantity for each line item. Use flowQuery to look up items by SKU in the inventory/items collection. Throw a bad_request error if any item has insufficient stock. Use the creating-hooks, flow-query, and hook-code-style skills."

The AI generates code that follows every convention, queries the right collections, handles edge cases, and validates inputs.

The Anatomy of an Effective Prompt

1. Name the Library and Method

Always specify where the hook lives. The AI uses this to set the file path, decorator metadata, and naming conventions.

❌ "Create a hook that..."
✅ "Create a new hook in finance-bills called calculate-late-fees..."

2. Describe the Trigger

ERP logic doesn't exist in isolation. Tell the AI when this logic runs.

❌ "Update inventory"
✅ "When a sales order status changes to 'confirmed'"

3. Specify Collections and Fields

The AI will guess field names if you don't provide them, and it will guess wrong.

❌ "Look up the product"
✅ "Use flowQuery to look up items by SKU in the inventory/items collection"

4. Define Error Conditions

Guard clauses and error handling are where AI-generated code is weakest. Explicitly state what should fail and how.

❌ "Handle errors"
✅ "Throw a bad_request error if any item has insufficient stock"

5. Reference Skills by Name

This is the multiplier. Skill references tell the AI exactly which knowledge to load.

❌ (no skill reference)
✅ "Use the creating-hooks, flow-query, and hook-code-style skills"

Prompt Template

Here's a copy-paste template for creating hooks:

Create a new hook in [library-slug] called [method-slug].

When [trigger condition], [primary action].
Use flowQuery to [query description] in the [collection-path] collection.
[Additional business rules].
Throw a [error-type] error if [failure condition].

Use the creating-hooks, flow-query, and hook-code-style skills.

Example using the template:

Create a new hook in shipping-management called calculate-shipping-rates.

When a sales order is ready for fulfillment, calculate shipping rates
for all available carriers. Use flowQuery to look up the order's line
items in the sales/orders collection and the customer's shipping address
in the contacts/addresses collection. Apply weight-based rate tiers
from the shipping/rate-tables collection. Throw a bad_request error if
the shipping address is missing or if total weight exceeds carrier limits.

Use the creating-hooks, flow-query, and hook-code-style skills.

Advanced Techniques

Ask for a Pattern Search First

Before generating new code, ask the AI to find similar existing hooks:

"Search for hooks that match the pattern 'calculate' in the finance-bills library."

This gives the AI a concrete reference implementation, producing much better results than generating from skills alone.

Chain Prompts for Complex Hooks

For hooks with multiple steps, break the work into sequential prompts:

  1. "Scaffold the hook structure for..."
  2. "Now implement the query logic using..."
  3. "Add validation for..."

Specify What NOT to Do

Negative constraints are surprisingly effective:

"Do not fetch all documents — use pagination. Do not calculate totals in JavaScript — use a database aggregation pipeline."

The Takeaway

Effective prompts for ERP logic follow a formula: name the location, describe the trigger, specify the data, define the errors, and reference the skills. The five minutes you spend writing a precise prompt save thirty minutes of fixing vague output.

Your prompt is your spec. Write it like one.

Letting AI Write Your FlowQuery — And Why You Still Need to Review It

· 3 min read
Gabriel Paunescu
Founder CTO Neologic

AI can chain FlowQuery calls with impressive accuracy — until it silently drops a tenant scope or builds an unindexed aggregation pipeline. Here's how to catch the mistakes that compile but corrupt.

The Premise

FlowQuery is Logic Bee's chainable ORM for tenant-scoped database operations. Its fluent API reads like pseudocode, which makes it a dream for AI code generation. The problem? Syntactically correct FlowQuery can still be semantically dangerous.

The Story

A developer asks the AI to build a reporting hook that aggregates monthly revenue across all posted invoices. The AI generates clean, readable code:

const invoices = await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.invoiceNaoQueryOptions)
.query({ 'data.status': 'posted' })
.getMany(undefined, 'FinanceInterface.Invoice')

Looks perfect. But during code review, the developer catches three issues that would have gone unnoticed in a test environment.

Where AI FlowQuery Goes Wrong

1. Missing Tenant Boundaries

The most critical mistake is also the quietest. When AI generates a helper function that internally creates a new FlowQuery chain, it sometimes forgets to pass the user context:

// ❌ AI-generated helper — no user scoping
async function getLineItems(fc, docId) {
return fc.docs.flowQuery()
.query({ 'data.parentId': docId }) // missing .user()
.getMany()
}
// ✅ Corrected
async function getLineItems(fc, bob, docId) {
return fc.docs.flowQuery()
.user(bob.flowUser) // always scope to tenant
.flowOptions(eventOptions.lineNaoQueryOptions)
.query({ 'data.parentId': docId })
.getMany(undefined, 'FinanceInterface.InvoiceLine')
}

2. Fetching Too Much Data

AI tends to use .getMany() when .getOne() would suffice, or fetches full documents when only a few fields are needed. In production with thousands of documents, this matters:

// ❌ AI default: fetch everything
const allOrders = await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.orderNaoQueryOptions)
.query({ 'data.customerId': customerId })
.getMany()

// ✅ Better: limit and project
const recentOrders = await fc.docs.flowQuery()
.user(bob.flowUser)
.flowOptions(eventOptions.orderNaoQueryOptions)
.query({ 'data.customerId': customerId })
.sort({ 'data.createdAt': -1 })
.limit(10)
.getMany(undefined, 'SalesInterface.Order')

3. Aggregation Pipeline Misuse

When asked to "sum" or "group" data, AI sometimes fetches all documents into memory and reduces in JavaScript instead of using database-level aggregation:

// ❌ AI approach: fetch-then-reduce
const invoices = await fc.docs.flowQuery()...getMany()
const total = invoices.reduce((sum, inv) => sum + inv.data.amount, 0)

// ✅ Better: use the database
const pipeline = [
{ $match: { 'data.status': 'posted' } },
{ $group: { _id: null, total: { $sum: '$data.amount' } } }
]

The 3-Point FlowQuery Review

Every time you review AI-generated FlowQuery code, check these three things:

Scoping

  • .user(bob.flowUser) on every chain — including helper functions
  • .flowOptions(eventOptions.*) matches the correct collection

Performance

  • .getMany() has appropriate limits or pagination
  • Aggregations happen at the database level, not in JavaScript
  • Queries use indexed fields

Correctness

  • TypeScript interface passed to .getMany(undefined, 'Interface.Type') for type safety
  • .getOne() used when only one document is expected
  • Null checks after .getOne() calls

The Takeaway

FlowQuery's fluent API makes AI-generated code look deceptively correct. The developer's job isn't to rewrite it — it's to verify the three dimensions the AI consistently underweights: tenant scoping, query performance, and data volume awareness.

Trust the syntax. Verify the semantics.