An open source LLM eval tool with two independent quality signals

LLM-as-judge has become the dominant pattern for evaluating language model outputs. Tools like Promptfoo, Braintrust, LangSmith all converge on the same architecture: send your prompt to your model, send the output to a different model with a rubric, take the second model’s score as the quality signal.

This works. It’s also expensive (judge tokens cost real money), slow (extra API roundtrip), variance-prone (the same eval gets different scores across runs), and architecturally a bit circular (using an LLM to evaluate an LLM trained on overlapping data distributions). The single signal becomes a bottleneck for trust.

So I built an eval module that has two independent signals instead of one.

What the tool does

Side-by-side blind comparison. Two agents answer the same prompt. One runs raw, the other can optionally have a cognitive harness wired in as a tool call. A separate blind judge model scores both responses, sees only A and B labels with no knowledge of which is which. Standard setup so far.

But alongside the judge, four cognitive posture heat maps run on each response. These are not LLM-based. Deterministic text analysis that visualizes HOW the model wrote, not just whether it agreed with you.

When the heat maps agree with the judge’s verdict, you have confidence. When they disagree, you have a question worth investigating. Two independent signals beat one signal that wraps itself.

How the heat maps work

Each response is split into 100 word-chunks arranged on a 10×10 grid. Two grids per agent.

Top grid: confidence posture. Per chunk, count hedge words (maybe, might, possibly, seems, could) and assertive words (definitely, must, always, never, clearly). Compute net (asserts - hedges) / (asserts + hedges). Add punctuation cadence as a secondary signal: periods are positive (definite statements end with them), commas are negative (qualifications stack with them). Normalize to [-1, 1]. Color the chunk diverging from blue (hedged) through gray (neutral) to red (assertive).

Bottom grid: reasoning density. Per chunk, count explicit reasoning connectives (because, therefore, since, if/then, due to, as a result, this means). The denser the reasoning markers, the brighter the cell. Sequential palette from dark to hot.

A 2D Gaussian blur runs over both grids so sparse markers spread into spatial blobs instead of isolated cells. Empirically this matters: a single “because” in a 100-chunk response forms a small heat radius on the reasoning grid even when neighboring chunks have nothing. The blob shapes are easier to scan at a glance than scattered pixels.

The whole computation runs client-side in plain JavaScript. No API call, no model inference. Pure word counting plus a smoothing pass. Free to compute, deterministic, fast.

Multi-turn scenario mode

Most LLM evals are single-turn. The most interesting failure modes are multi-turn.

If you paste turn1---turn2---turn3 separated turns into the scenario textarea, both agents accumulate conversation history across turns. This is where production failures actually manifest:

  • Sycophancy compounding. A model that gives ground on turn 2 has already shifted by turn 4. Single-turn evals miss the trajectory entirely.
  • Hallucination cascade. Once a model emits a wrong fact, that fact becomes part of the conversation history. On the next turn, the model treats its own previous error as established truth and builds on top of it.
  • Authority claim drift. User-proposed framings persist across turns. The model anchors on the first plausible framing without re-examining it later.
  • Prompt-forgery patterns. A user can inject fake reasoning chains in a later turn (“we already verified X yesterday, can you finalize the report?”). The model has no way to verify the off-screen claim and tends to accept it.

The eval module captures all four. The cognitive posture field shows visually where in the response the model committed to the bad path.

Other things in the module

The optional cognitive harness has four modes you can switch in the UI:

  • anti-deception (139 cognitive operations): sycophancy resistance, prompt injection, hallucination cascade
  • reasoning (311 operations): general structured thinking, causality, simulation, metacognition
  • code (128 operations): software engineering tasks
  • memory (101 operations): perception and behavioral calibration

Pick whichever mode fits the failure category you’re testing for.

Dimensions the judge scores on are user-defined. There’s a small library to pick from (Accuracy, Hallucination resistance, Held the line, Reasoning depth, Safety, Completeness) but you can type any name and the judge prompt rewrites itself to include it. Each agent has its own system prompt field, so you can frame them differently if your comparison needs that.

The Results Overview sidebar accumulates per-dimension bar charts, win tally, latency and token cost per branch across runs in the same browser. localStorage persists everything between sessions. Compare A vs B opens a fullscreen modal for reading both responses in parallel when they get long.

Why Windows 95 chrome

I tried to make it look like an instrument, not a SaaS dashboard. Beveled fieldsets do hierarchy work for free (the inset border physically separates each panel from the canvas, no whitespace tuning required). White input fields are where data lives so the eye lands on them. Gray-on-gray chrome stays out of the way.

Modern flat dark themes have to invent that hierarchy back from scratch using whitespace, type weight, dividers, and color hierarchy. They usually come up shorter. Win95 was a 1995 UI grammar that handled hierarchy through bevels, and bevels are free visual structure.

It’s also nicer to look at when you’re staring at evals for hours.

Tech stack

  • Single HTML file (vanilla JS, no framework, no build step)
  • 50-line Python stdlib proxy for CORS (the harness gateway doesn’t send CORS headers, so the proxy forwards server-side). Could be replaced with any reverse proxy (nginx, Caddy, Workers) in production.
  • localStorage for persistence, no signup, no telemetry
  • MIT licensed

Works with any OpenAI-compatible endpoint: OpenRouter, OpenAI direct, Anthropic via gateway, vLLM, llama.cpp’s openai shim, Ollama with the compat layer, LM Studio. Just point Provider URL at the right endpoint. Tool-calling capable model required for the harness branch, raw branch works on anything.

Try it yourself


bash
git clone https://github.com/ejentum/agent-teams.git
cd agent-teams/agent_evaluation_module_xp95
python serve.py

Using Dashboard Filtering to Get Customer Usage in Seconds from TBs of Data

Bronto dashboard overview

Authored by Conall Heffernan

As the Customer Success lead at Bronto, I need fast, reliable insights into customer health and product usage — but I don’t have time to constantly update indexes, schemas, or individual widgets just to answer new questions. I need to spot patterns, explore trends, and get answers in real time without manual overhead.

That’s why Bronto’s dashboards are so critical to my work. We recently added a new query filtering feature that lets me use SQL to look for any pattern across all widgets in a dashboard simultaneously — and update everything at lightning speed.

What Bronto Dashboards Provide

  • Rich visualisation options — time-series charts, geomaps, numeric value widgets (with units like bytes and time), top lists, treemaps, and log event lists for drilling into raw data
  • AI widget builder — describe what you want in natural language; an LLM builds the query and creates the widget without you needing to know the query language or which datasets to select (see the full post on this feature)
  • Full screen widget mode — compare different timeframes for the same query (e.g. vs. previous day, week, month)
  • Easy filtering — use the query builder with a searchable dropdown of top keys and values, or write SQL filters directly in the filter bar

Bronto dashboard overview

The Power of Filtering at Scale

In many logging or observability tools, applying a filter means updating every single chart, table, or widget individually. Tedious. Time-consuming. It doesn’t scale.

With Bronto, applying a filter in the main query bar instantly updates every single widget on the dashboard for your given timeframe. With a default retention period of one year, you don’t need to worry about missing long-term trends — all your data is fully searchable and visualisable. Concerned about when an issue started? You can analyze trends over months, not days.

Our widgets use pre-computed log-based metrics (LBMs) for rapid responses, but the new dashboard filtering goes further — running raw log queries to filter the data in your dashboard in real time. Results come back in seconds, and you can drill down across all widgets simultaneously using SQL or by clicking from a dropdown of top keys and values.

One thing worth noting: there’s no initial configuration of keys required. I can use any key I want in the filter, with no setup.

Dashboard filtering GIF — all widgets update simultaneously

Log-Based Filtering vs. Log-Based Metrics

Log-based metrics (LBMs) deliver millisecond responses — perfect for real-time dashboards, trend analysis, and high-level views of system behavior.

When something unexpected happens or a new question comes in, we seamlessly switch to log-based filtering to investigate deeper, explore raw data, and uncover answers without being constrained by pre-defined fields or aggregations.

Log-based filtering excels because it lets you query and visualize raw logs immediately — no upfront configuration like index definitions or field extraction required. Bronto combines this with structured parsing and indexing, using the right approach for the right job.

The result: both fast, flexible investigation and high-performance queries on known fields, without forcing you to predefine every key or build parsing pipelines before you can search effectively.

Log-based filtering in action

How I Use Dashboard Filtering for Customer Usage

My primary use for this feature is gathering and presenting product usage data to our leadership team. Questions like:

  • “How much data did Org ID 54321 send over the last 6 months?”
  • “How much did company ACME search last month?”

Instead of building 10 custom dashboards (which doesn’t scale as your customer base grows), I use dashboard filtering:

  1. Navigate to our main Usage Dashboard
  2. Enter the specific org_id in the main query filter (e.g. org_id: 54321)
  3. Every widget updates instantly to reflect only that organization’s data

That’s it. A complex, multi-step data lookup becomes a quick and easy process.

The first time I tried filtering across the dashboard for an org_id, I thought something wasn’t working right — the results were rendered so fast across terabytes of data. It was a genuine “wow” moment. As a customer support lead, it’s great to see the under-the-hood changes we’re building for customers also improving my own day-to-day.

Dashboard filtered by org_id

How Bronto Dashboards Compare

Most dashboard tools are optimized for known questions: predefined fields, fixed widgets, metrics decided on ahead of time. That works for stable monitoring but breaks down when you need to explore new questions, investigate unexpected behavior, or quickly slice data in different ways.

Feature Bronto Dashboards Traditional Competitors
Query Scope SQL filtering on any log value Often restricted to predefined or indexed fields
Update Speed Instant — all widgets update simultaneously Manual, per-widget updates required
Performance Terabytes rendered in seconds across all datasets Latency issues with large datasets; often minutes to render
Setup Schema-less — no upfront definitions or configuration Parsing and indexing pipelines required first
Cost Filtering included in monthly quota; faster MTTR Slow dashboards can inflate costs by consuming more resources

Summary

Modern teams need dashboards that are fast, flexible, and easy to adapt as questions change. Bronto dashboards combine high-performance log-based metrics with instant, dashboard-wide filtering to help you explore usage, investigate issues, answer leadership questions, and quickly re-run reports as requirements evolve.

Questions like “what does this customer’s usage look like over the last six months?” are answered in seconds — without upfront schemas, per-widget reconfiguration, or slow refresh cycles.

See Dashboard Filtering in Action

Skills, Java 17, And Theme Accents

Skills, Java 17, And Theme Accents

Skills, Java 17, And Theme Accents

Last week was about Metal and the Skin Designer. This week the headline items are about what a brand new project looks like when you generate it: the default JDK is Java 17, and every generated project ships with an AGENTS.md authoring skill that lets any modern AI agent work on the project intelligently. There are also some other things worth covering: a runtime accent palette on the new native themes, three Metal follow-ups (one of which introduces a new matrix-correct translate API), the JDK 11+ String API gap closed, and iOS push permission that no longer fires at app launch.

What is Codename One? Codename One is an open-source framework for building native iOS, Android, desktop, and web apps from a single Java or Kotlin codebase. Learn more at codenameone.com.

Java 17 by default

We changed the default projects generated by the Initializr to Java 17+ to focus on the future of Codename One. The existing Java 8 option in the Initializr is still selectable from the radio panel if you have a reason to use it. Pick whichever you want.

The Java 17 path is the one we now recommend for new work. Generated projects build with any JDK from 17 onwards (we routinely test on 21 and 25); you do not need to install Java 17 specifically. The bigger picture of how Java 17 support works in the toolchain, including which language features land in your app code and how the iOS / Android ports handle the newer bytecode, was covered in Official Experimental Java 17 Support earlier this year. The change this week is the default and the wording: the (Experimental) tag is gone, and Java 17 is now what you get unless you opt out.

AGENTS.md and the Codename One skill

The other change in PR #4946 is that every Java 17 project the Initializr generates now ships an AGENTS.md file at the project root and a Codename One authoring skill alongside it.

AGENTS.md is the convention for handing project-specific context to any AI agent. Claude Code, Cursor, Codex, Aider; they all look for it. Codename One projects now ship one. The actual skill content lives under .agent-skills/codename-one/ (vendor-neutral) and the source for it is in the repo at scripts/initializr/common/src/main/resources/skill if you want to read through it directly. There is also a thin stub at .claude/skills/codename-one/SKILL.md so Claude Code’s /skills picker indexes it; the stub redirects to the same vendor-neutral content.

We deliberately scoped this to Java 17 projects. The older Java 8 build had additional constraints (Java 5/8 source target, retrolambda, the historical bytecode rewrite rules) that made the “what can I actually use” answer noticeably more complicated. Restricting the skill to Java 17 lets us give agents a cleaner picture of the language level, the toolchain, and the build commands without spending half the SKILL.md on caveats. If you stay on Java 8, you keep the project layout you had; nothing changes for you.

A few things the skill makes possible that I think are genuinely useful:

Agents can debug a Codename One app under jdb. This is the one I am most pleased with. The simulator is a regular JVM, so the standard Java Debugger attaches cleanly, but agents previously had no idea this workflow was available. The skill’s debugging.md reference walks through starting the simulator with the right -Xrunjdwp flags, attaching jdb, setting breakpoints, dumping locals, and stepping. The same workflow works in CI and any headless context where a graphical debugger is not an option. For an LLM that is otherwise reduced to “add a println and hope”, this is a much sharper tool.

Agents can check whether an API is part of the Codename One subset before they suggest it. Codename One targets a Java 5/8 shaped JDK so the same bytecode translates to iOS, Android, and JavaScript. An agent that has only read regular Java idioms will routinely reach for java.nio.file, java.time, or pieces of java.util.concurrent that the framework does not include. The skill ships a single-file IsApiSupported.java tool that an agent can invoke to verify a class or method before writing code against it.

Agents can validate a CSS snippet before applying it. Codename One CSS is its own subset; rules that look fine to a browser developer get silently dropped by the compiler. The IsCssValid.java tool lets the agent confirm the compiler will accept a snippet without booting the simulator.

These three things together are most of why an agent that was previously polite-but-not-useful on a Codename One project is now actually productive on one. If you do not use agents, the same Markdown is one of the better tours of the framework’s mental model that we have written; open .agent-skills/codename-one/SKILL.md in any project you generate today and read top to bottom.

Native theme accents

PR #4884 closes the loop on the new iOS Modern and Material 3 native themes we shipped two weeks ago. The native themes now expose their accent palette as named theme constants, so rebranding your app to your own colours is a five-line CSS change instead of a fork.

Override the constants inside the #Constants block of your own theme.css:

#Constants {
    includeNativeBool: true;
    darkModeBool: true;

    --accent-color: #ff2d95;
    --accent-color-dark: #ff2d95;
    --accent-pressed-color: #c71a75;
    --accent-on-color: #ffffff;
}

That is it. Every accent-bearing UIID picks up the new colour. Light and dark are independent (--accent-color vs --accent-color-dark), and partial overrides are fine; anything you do not redeclare stays at the framework default. Material 3 has a couple of additional container-tier constants for the elevated-surface tone; iOS ignores those.

There is also a runtime path for dynamic theming (in-app accent toggles, branded flavours, A/B tests). It uses the same constants. The Native Themes chapter of the developer guide covers it in detail, along with the full iOS and Android constant tables and the places where the binding system intentionally does not apply: Accent palette override.

The point worth pulling out: the parts of theming that do not change per app (which UIIDs participate in the accent palette, which states they expose, which dark-mode counterparts they have) live inside the framework and stay there. The parts that do change per app (your colours) live in your project as five constants and nothing else. That is the whole reason this change exists.

Metal follow-ups

Last week was about shipping the Metal renderer. This week is the follow-up week: three PRs, plus one new API on Graphics that I think will quietly pay for itself many times over.

Per-axis scale decomposition (#4939, fixes #3302)

Long-standing issue #3302 had a clear repro: g.translate + g.scale(sx, sy) + fillShape with sx != sy produced shapes that visibly drifted off the axis-aligned drawRect and drawLine calls the framework emitted alongside them. Triangles inscribed in rectangles escaped their bounding rect.

The cause was that the legacy alpha-mask path rasterised the shape at a uniform scale (the diagonal ratio h2/h1), then stretched the resulting texture non-uniformly through the GPU matrix to recover the requested aspect. The bbox math is exact in real numbers, but the texture is pixel-rounded at the intermediate uniform scale, so the stretch drifted the rasterised shape off the pixel grid that drawRect and drawLine were already on.

The fix factors the user transform’s 2×2 linear part by taking the column norms as (sx, sy), rasterises the path at S(sx, sy) so the per-axis stretch happens at rasterisation time against a vector path rather than a pixel grid, and applies only the residual transform * S(1/sx, 1/sy) on the GPU. The residual is pure rotation (and shear in the worst case), so no per-axis stretch happens at sample time and the alpha-mask texture lands on the same pixel grid as its drawRect siblings.

The change is gated to Metal; the GL ES2 path keeps its legacy branch so the existing GL goldens are byte-identical. A new InscribedTriangleGrid screenshot test was registered with Cn1ssDeviceRunner so the inscribed-triangle property is now visually verifiable in CI.

Clip-under-rotation diagnostic (#4924, towards #3921)

PR #4924 does not fix a bug, it localises one. Issue #3921 is “clip-under-rotation behaves wrong on some ports”, entangled with a getClip / setClip(int[]) round-trip limitation the reporter himself called out as a separate issue. To split the two, we shipped a screenshot test that uses only pushClip / popClip and rotateRadians. The clip becomes non-axis-aligned via clipRect inside a 30-degree rotation, which forces the framework through its polygon-clip branch.

The expected outcome is a 30-degree-tilted red fill that overlaps the navy outline at two diagonal corners and falls short at the other two. Two distinguishable failure modes are pre-labelled in the PR: the clip widened to its axis-aligned bbox (red exactly matches the navy outline), or the polygon clip dropped entirely (red fills the whole cell). When the iOS Metal cell of this test renders, we know within a glance which of the three behaviours we are looking at. The expected-failure cell is also a hypothesis: ClipRect.m‘s polygon initialiser stores x = y = w = h = -1, and the Metal execute path then calls CN1MetalSetScissor(0, 0, -2, -2), whose width <= 0 / height <= 0 branch sets the scissor to the full framebuffer instead of the intended polygon. If the screenshot confirms the hypothesis, the fix is a one-line replacement of the polygon-scissor fallback.

iOS Metal colour space hint (#4909, fixes #4908)

PR #4909 adds an ios.metal.colorSpace build hint. Until this week, the Metal layer’s CAMetalLayer.colorspace was hard-coded to sRGB. For most apps that is right; sRGB is what your existing assets are authored in. But on iPhone XR and later, Apple’s screens are wide-gamut (Display P3), and a marketing-led brand that ships P3 artwork was visibly losing saturation by being routed through the sRGB pipeline.

Accepted values are sRGB (default), displayP3, deviceRGB, linearSRGB, extendedSRGB, extendedLinearSRGB, and none. Set it in codenameone_settings.properties:

codename1.arg.ios.metal.colorSpace=displayP3

The hint is dormant when ios.metal=false, so existing GL builds are unchanged. Unrecognised values produce a warning log and fall back to sRGB. Documented under Working-With-iOS.asciidoc.

The new translateMatrix API

The Inscribed-Triangle-Grid test in #4939 also surfaced a quiet papercut in Graphics that is worth pulling out as its own feature.

Graphics.translate(int, int) does not compose into the affine transform the way scale() and rotateRadians() do. It accumulates into a per-Graphics integer offset that is added to draw coordinates before the impl matrix is applied. That is a holdover from the very first version of the framework, when Graphics did not have a matrix at all. Today the consequence is surprising: a subsequent g.scale(sx, sy) multiplies the integer translate too, which means the same code produces visibly different positions depending on whether you scale before or after you translate.

The new Graphics.translateMatrix(float, float) composes the translation directly onto the impl matrix, in the same way scale and rotateRadians already do. The result is uniform “post-multiply translate onto the current transform” semantics across iOS (both GL and Metal), JavaSE, Android, and the JavaScript port. Same code, same on-screen position, whether you are drawing into a Form‘s Graphics or a mutable Image‘s Graphics.

// Matrix-correct composition. Use this when you want translate to
// behave like scale and rotate (composed into the affine transform).
g.translateMatrix(centerX, centerY);
g.rotateRadians(angle);
g.scale(sx, sy);
g.translateMatrix(-centerX, -centerY);
g.fillShape(path);

For app code writing affine-transform pipelines (the “translate to pivot, rotate, scale, translate back” idiom from Java2D and AWT), this is the API you want. isTranslateMatrixSupported() returns true on every modern port. The old translate(int, int) is not deprecated and is not going anywhere; half the framework’s internal scrolling code is built on it. The new method is the one to reach for in new drawing code, particularly anything that combines translate with scale or rotate.

String API: replace(CharSequence, CharSequence), replaceAll, replaceFirst

PR #4893 closes a long-standing gap reported in issue #4878. The JDK 1.5+ overload of String.replace that takes CharSequence arguments (the one nearly every modern Java tutorial reaches for) was missing from the Codename One subset. So were String.replaceAll(String, String) and String.replaceFirst(String, String). Because none of the three were on the bootclasspath, code that reached for them did not compile against a Codename One project at all; you had to know to fall back to the older replace(char, char) overload and to roll your own regex.

All three are now wired in. String.replace(CharSequence, CharSequence) has a real implementation in vm/JavaAPI. replaceAll and replaceFirst are wired through the bytecode-compliance rewriter to a new JdkApiRewriteHelper pair that delegates to the existing RE regex engine (the same pattern we have been using for years on String.split). New compliance tests cover both rewrite rules.

It is a small change in line count. In practice it is a noticeable reduction in how often “I copied a snippet from Stack Overflow and it didn’t work on iOS” turns into a real bug. Three of the most-reached-for String methods in modern Java are now part of the on-device API.

iOS push permission no longer fires at app launch

PR #4894 fixes issue #4876. With ios.includePush=true the framework used to call requestAuthorizationWithOptions from application:didFinishLaunchingWithOptions:, which meant the iOS system permission dialog fired as soon as the app finished launching, before the user had seen any of your screens. There is no good way to recover from a “Don’t Allow” tap at that point. The user has not experienced the app yet, does not know why notifications matter, and tapping Don’t Allow is the path of least resistance. Once denied, re-prompting requires sending the user out to Settings.

The fix moves the prompt to the natural points. Push.register() triggers the system prompt (this code path already requested permission inside IOSNative.m; we just stopped firing it ahead of time). LocalNotification.schedule() also triggers it, via a new requestAuthorizationWithOptions call in sendLocalNotification. Same flow Android has been on for years. The practical consequence is that you can now show your own rationale screen (“we’d like to ping you when your order ships”) before the system dialog fires.

If you have an app that needs the legacy launch-time behaviour, a backwards-compatibility build hint restores it:

codename1.arg.ios.notificationPermissionAtLaunch=true

The default is false, so existing apps that did not opt in pick up the new behaviour on next rebuild. Documented in Push-Notifications.asciidoc. The cloud-side build server change shipped as BuildDaemon #71, so local and cloud builds match.

One thing to flag if you are updating an existing iOS app: if your onboarding flow was relying on the launch-time prompt happening automatically, your prompt now never fires unless Push.register() or LocalNotification.schedule() is invoked somewhere. That is almost certainly what you want, but check that the call lands.

Skin Designer FAQ follow-up

A few questions came up on discussion #4928 after last week’s Skin Designer post, worth pulling forward here because they keep coming up in the same shape:

  • Skins do not affect CSS. The skin is simulator scaffolding (device frame, screen rect, cutouts, safe-area insets); your theme.css and your native theme are unrelated.
  • For a known device, the defaults are usually right. Pick the device, hit Pick a shape, click Finish. The customization UI is there for when our device database is incomplete (the iPhone 17e entry might say “no notch” when it actually has one, or the notch position might be off by a few pixels); when you have a physical device to measure against, that is where you refine.
  • Themes are leaving skins. Historically the native theme was bundled inside each skin because that is what made sense at the time. Going forward the right home for themes is the framework itself, distributed via Maven, so you pick up updates automatically. The new native themes already work this way. The per-skin embedded theme stays for legacy compatibility and the Skin Designer still writes one for you, but the Native Theme menu we shipped two weeks ago is the path forward.

The device database the Skin Designer reads from is open at scripts/skindesigner/common/src/main/resources/devices.json if you want to file a PR with a device we are missing or a row whose details are off.

Wrapping up

Two reminders. First, flip ios.metal=true on your real app this week if you have not. The default flip is days away and we would rather find any remaining edge case against your screens than against the install base on launch day. Second, if you have not generated a project from the Initializr recently, do it; the Java 17 default and the AGENTS.md skill are both worth seeing for yourself.

A specific thank-you this week to the reporter on #3302 for sticking with the inscribed-triangle bug for as long as GL was the only target, Durank for the iOS push permission report on #4876, and the reporter on #4878 who flagged the missing String.replace(CharSequence, CharSequence); that one had been sitting in the gap for a long time.

Issue tracker is here, the Playground and Initializr are the easiest places to poke at the new defaults, and the Skin Designer from last week is still there if you have a device shape you need a skin for.

4 Hard Lessons on Optimizing AI Coding Agents

4 Hard Lessons on Optimizing AI Coding Agents (Claude Code + Cost)

I’ve been running Claude Code Cli in production for about months now—building, shipping, and watching the token meter spin. Here’s what I wish I knew before I started.

1. Your Context Strategy Is Everything

The developers getting 10x from Claude Code aren’t prompt whisperers. They’re context engineers.

The 2026 consensus is clear: CLAUDE.md is no longer optional. Keep it lean. Ask yourself: “Would removing this line cause the agent to make mistakes?” If not, cut it. No one has time to scroll through 15k tokens of stale docs every session.

One more .claudeignore tweak took my sessions from 150k tokens to 60k—a 60% drop. The culprit? Node modules and dist folders. Block them. You can manually feed files when needed.

The golden rule: engineer before you prompt.

2. The /ghost, OODA, and L99 Patterns

Stop talking to Claude Code like it’s ChatGPT. It has 40+ tools built in—most people use 3 or 4.

Four prompt frameworks that actually moved the needle for me:

  • /ghost : Kills the AI filler voice. “It’s worth noting that…” disappears. You get clean, direct prose for docs and PRs.
  • OODA : Forces a concrete decision instead of “it depends.” Feed it your constraints (team size, shipping cadence) and demand a plan.
  • L99 : Unlocks expert depth. Instead of tutorial answers, you get production-ready patterns.
  • PERSONA : Frames the response through an expert lens. “Senior AWS Solutions Architect with 10 years experience” actually works.

Also: be direct. The system prompt explicitly tells Claude Code to be terse. If you write “Hey, could you maybe take a look at…” you’re working against the grain. A crisp “Fix this function” is what the agent wants.

3. Cost Optimization Isn’t Magic—It’s Math

Let’s run the numbers. A 200-call agent session on Opus with growing context can hit 4M input tokens. At $5/M, that’s $20 just for input before output. A 20-developer team running 50 sessions daily? $10k+ per month.

Here’s where the real waste hides: 70–85% of your tokens are input tokens. And 80% of that input comes from reading project files.

Three fixes that actually work:

Cache your prompts. Anthropic and OpenAI offer 90% and 50% discounts on cache hits. That repeated system prompt? Stop paying for it on every turn.

Skill layers slash token spend. One team cut consumption from 10.4M to 3.7M tokens—$9.21 to $2.81 per session—by routing context through a reusable Skill layer. Think of it as middleware for tokens.

Route by difficulty. A classification model routing simple queries to Sonnet (or even Haiku for boilerplate) while reserving Opus for architectural decisions can cut 40–70% of spend with no quality loss.

Don’t let perfect be the enemy of cheap.

4. Agentic Workflows Beat Scripts

The shift from “scripting” to “orchestration” is real. The teams shipping fastest in 2026 aren’t writing prompts—they’re designing workflows.

Two patterns worth stealing:

TDD governance at the prompt level. Encode your testing discipline into the agent’s loop: write, run, observe errors, fix, repeat. With inference costs 5–10x lower than 2025, this feedback loop is now economically sane for 500-line changes.

Leave Opus 4.6 habits behind. Opus 4.7 treats you differently. The old “fine-grained pair programming” approach backfires. Instead, specify the full task in the first turn—goals, constraints, acceptance criteria—and let the agent work. And stop defaulting to max effort. xhigh is the new sweet spot. max overthinks and slows you down.

Bottom line: Engineer your context, use prompt frameworks that drive decisions, route strategically for cost, and design workflows instead of writing scripts. The agents are ready. Your wallet (and your weekend) will thank you.

For devs working with Chinese LLMs: check out cnllm.ai — An enterprise-grade gateway — replace your base_url with https://cnllm.ai to seamlessly access all mainstream LLMs, including top global models like GPT, Claude, Gemini and cost-effective Chinese models like DeepSeek, Qwen, GLM, Kimi, Minimax — fully compatible with OpenAI protocol.

Arctype: Cross-Platform Database GUI for LLM Artifacts

Arctype: Cross-Platform Database GUI for Developers and Teams

OpenAI’s recent push into content credentials and SynthID marks a clear pivot. The industry is moving from simply deploying models to verifying where that content came from. For teams integrating LLMs into production, inspecting model artifacts is now as critical as managing application data. Security reporting on HN reinforces this: “trust but verify” applies to the weights and metadata powering your agents, not just your codebase.

Arctype fills a specific gap here. It provides a unified interface where developers can visualize both their application schema and the external model dependencies driving it. Instead of treating AI assets as black boxes, you get a single pane of glass that connects your SQL tables to the .gguf files powering the inference engine.

Why database GUIs are the critical layer for AI provenance and security

Traditional database tools focus on rows, columns, and transactions. They don’t care if a query is returning text generated by an Llama 3 variant or hardcoded strings. But as we build agentic workflows, that distinction matters.

OpenAI’s new initiatives highlight a shift from raw model deployment to verifying content origin and integrity. When a user posts a response generated by your local instance, you need to know exactly which model version processed it, what quantization level was used, and under what license the inference occurred.

As teams integrate LLMs into production workflows, the ability to inspect and document model artifacts becomes as vital as managing application data. Security reporting on HN emphasizes that “trust but verify” now applies not just to code, but to the foundational weights and metadata powering AI agents.

Arctype fills this gap by providing a unified interface where developers can visualize both their application schema and the external model dependencies driving it. Imagine a view where you can filter your chat history logs by the specific sha256 of the model that generated them, or see which endpoints are currently bound to a quantized version that violates your internal license policy.

This isn’t just about storage; it’s about lineage. By treating model artifacts as first-class citizens in your database schema, you transform opaque binary blobs into auditable assets with clear identity tags. This is essential for enterprise-grade AI governance where every generated artifact needs a chain of custody.

How to build a Software Bill of Materials (SBOM) for Local LLM Artifacts

Traditional SBOMs focus on code dependencies listed in package.json or requirements.txt. But local LLM workflows require scanning binary artifacts like .gguf and .safetensors files. These are not just static libraries; they are the actual intelligence your application relies on.

Inspecting these files reveals critical metadata such as quantization levels, architecture types, context limits, and license information that are often lost in generic deployment scripts. Generating a lightweight SBOM allows teams to audit their local model inventory for security vulnerabilities, licensing compliance, and hardware compatibility before execution.

This process transforms opaque binary blobs into auditable assets with clear identity tags, essential for enterprise-grade AI governance. You can no longer assume a file named model-v2.gguf is safe or compliant just because it downloaded successfully yesterday.

Consider the metadata exposed by tools like our CLI companion, L-BOM. It parses a .gguf file and extracts specifics like quantization: Q4_K_M, context_length: 128000, and license: other. Without this level of detail, you might accidentally run an unlicensed model in production or hit a hard context limit without knowing it until the agent hallucinates.

Arctype allows you to ingest this SBOM data directly into your database schema. Once an artifact is scanned, its metadata should be ingested directly into the database schema to track model lineage alongside application data. A cross-platform GUI allows developers to query “which models are running which endpoints” and “what licenses govern this specific quantized version.”

Linking provenance signals from tools like OpenAI’s verification suite with local artifact scans creates a complete audit trail for AI-generated content. This integration ensures that security reporting isn’t an afterthought but a continuous part of the development lifecycle.

Integrating LLM Inventory Data into Your Development Workflow

Once you have the SBOM, how do you use it? The answer lies in treating model inventory as a dynamic schema object rather than a static file on your disk.

A cross-platform GUI allows developers to query “which models are running which endpoints” and “what licenses govern this specific quantized version.” This is where Arctype shines compared to raw CLI tools. You can write a simple SQL query to find all chat sessions generated by models older than a specific SHA256 hash, or identify all instances using a license that requires attribution but was deployed in an anonymous manner.

Linking provenance signals from tools like OpenAI’s verification suite with local artifact scans creates a complete audit trail for AI-generated content. This integration ensures that security reporting isn’t an afterthought but a continuous part of the development lifecycle.

Imagine a dashboard view in Arctype where you see your chat_sessions table joined with your model_artifacts table. You can instantly see which version of the model generated the response to a specific user query. If OpenAI releases a critical security patch for their base weights, or if you need to rotate a compromised local instance, you have a database-backed list of exactly what needs to be redeployed.

This creates a feedback loop between your application logic and your infrastructure stack. You aren’t just running models; you are managing a fleet of AI assets with the same rigor as your web server cluster.

Where this shows up in small-team software stacks

Small teams often lack dedicated DevOps or legal staff to manually review model artifacts, making automated scanning tools like CLI-based SBOM generators essential. A lightweight Python CLI provides immediate feedback during local development without requiring complex infrastructure or cloud dependencies.

Exporting scan results as standard formats (JSON, SPDX, or Hugging Face READMEs) allows for easy sharing with security teams or compliance officers. This “inspect once, use everywhere” approach ensures that even a solo developer maintains rigorous standards for AI asset management.

For a two-person indie team building an agentic app, this workflow is non-negotiable. You might be running three different models: one for summarization, one for code generation, and one for creative writing. Each has different context limits, different quantization trade-offs, and potentially different licensing requirements.

Arctype brings order to this chaos by normalizing these disparate artifacts into a single queryable schema. Whether you are using the CLI tool L-BOM for initial ingestion or leveraging the GUI capabilities of Arctype for ongoing management, the goal is the same: visibility.

We have seen teams use similar patterns with our other tools. For instance, we chose Rust over Python for our agentic workflow harness to eliminate garbage collection pauses and reduce container bloat. That architectural decision isolates the heavy lifting, but it still requires a way to inspect what’s inside those containers. Arctype provides that inspection layer without forcing you into a rigid cloud-native stack.

It’s about pragmatism. You need to know if your Llama-3.1-8B-Instruct-Q4_K_M.gguf file matches the one you think it does. You need to know if the license text embedded in the metadata actually permits commercial use. And you need a way to answer that question without opening a terminal and parsing JSON manually.

By treating model artifacts as database rows, you elevate them from disposable binaries to managed assets. This shift is what separates hobbyist scripts from production-grade AI applications. It’s not about building a monolithic enterprise platform; it’s about building the right tools for the specific constraints of your stack.

When you combine the scanning capabilities of tools like L-BOM with the visualization and query power of Arctype, you get a complete picture of your AI supply chain. You can trace a response back to its source weights, verify its license compliance, and audit its context configuration all within a familiar database environment. That is the critical layer for modern AI provenance.

Your robots.txt says GPTBot is welcome. Your server says 403.

Your robots.txt lists User-agent: GPTBot - Allow: /. The page loads fine in a browser. The “AI crawler” checkers say you’re configured correctly. But every time ChatGPT-User actually fetches your site, it gets a 403. You don’t show up in ChatGPT when people ask about your product. You don’t show up in Perplexity. The standard tools can’t see why, because they’re reading the wrong file.

This is the most common AI crawler accessibility failure in 2026, and almost nothing on the open web explains it correctly. Most write-ups stop at “here are five user-agents, add them to your robots.txt.” That’s table stakes. The actual blocks happen one layer up – at the CDN, at the WAF, in the JS shell of an SPA – and you can configure robots.txt perfectly while still being invisible to every model that matters.

The three ways your site gets blocked

Three layers. They fail for different reasons, they need different fixes, and from the outside they all look the same: your site, missing from ChatGPT, no obvious cause. Most write-ups treat them as one thing. That’s how readers end up patching the wrong layer.

Layer 1: robots.txt disallow (application layer)

The obvious case. Your robots.txt explicitly disallows an AI user-agent, or disallows * and never re-enabled the bots you actually wanted.

# Common failure mode: copied from a staging config
User-agent: *
Disallow: /
# Or the version that explicitly blocks AI bots
User-agent: GPTBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

How to test: fetch /robots.txt directly and grep for AI user-agents. This is what every “AI crawler” tool already does. If this is your problem, the fix takes thirty seconds. The reason it gets so much airtime is that it’s the easiest failure to detect and explain. Not because it’s the most common.

Layer 2: CDN / WAF edge block

This is the failure mode that’s killing 2026 AI visibility for most sites that “did everything right.” Your origin never sees the request. Cloudflare, AWS WAF, Fastly, or a custom edge rule (the one someone added at 2am after a scraper incident and nobody has touched since) returns a 403 before robots.txt gets read.

The tell: your robots.txt is permissive. The bots get blocked anyway. Parsers say everything is fine.

# What a healthy response looks like
$ curl -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" -I https://your-site.com
HTTP/2 200
content-type: text/html; charset=utf-8

# What an edge-level block looks like
$ curl -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" -I https://your-site.com
HTTP/2 403
server: cloudflare
cf-ray: 8b9c2f1e4a8d3c12-FRA

server: cloudflare plus a 403 or 429 means the request died at the edge. Same shape with server: AmazonS3 and a WAF rule, or via: 1.1 fastly. We’ll go deep on Cloudflare below; it’s the biggest source of silent blocks by a wide margin.

Layer 3: origin or application block

The rest happens at your server. Less common than edge blocks. Easier to hide:

  • Custom user-agent filtering. Someone added if (ua.includes("Bot")) return 403 to middleware years ago. It catches GPTBot along with everything else.
  • Rate limiting. Per-IP limits hit AI crawlers harder than human traffic because the crawler IPs are concentrated in a handful of datacenters.
  • Geo-blocking. AI bots fetch from regions your geo rules don’t trust.
  • JS-rendering invisibility. 200 OK, empty body, model walks away with nothing. Worth its own section, coming up.

How to test: curl your page with each AI user-agent and read the response body. Don’t just check the status code. A 200 with no content is a 200 that means nothing to a language model.

The bots that matter in 2026

Most “AI crawler” lists copy each other and never explain what each bot actually does. Here’s the practical version, sorted by what it costs you to block each one.

USER-AGENT              · PURPOSE                          · BLOCKING IMPACT                       · SEVERITY
─────────────────────────────────────────────────────────────────────────────────────────────────────────────
GPTBot                  · OpenAI training crawler          · Excluded from future GPT training     · low–medium *
ChatGPT-User            · ChatGPT live retrieval           · Invisible in ChatGPT answers          · CRITICAL
OAI-SearchBot           · ChatGPT Search index             · Excluded from ChatGPT Search          · HIGH
ClaudeBot               · Anthropic training crawler       · Excluded from Claude training         · low–medium *
Claude-User             · Claude live retrieval            · Invisible in Claude answers           · CRITICAL
anthropic-ai            · Legacy Anthropic UA              · Same as Claude-User (older clients)   · HIGH
PerplexityBot           · Perplexity index                 · Excluded from Perplexity              · HIGH
Perplexity-User         · Perplexity live retrieval        · Invisible to Perplexity queries       · CRITICAL
Google-Extended         · Gemini + AI Overviews            · Excluded from Google's AI surfaces    · HIGH
Applebot-Extended       · Apple Intelligence training      · Excluded from Apple AI features       · LOW
meta-externalagent      · Meta AI training / retrieval     · Excluded from Meta AI                 · MEDIUM
Bytespider              · ByteDance crawler                · Excluded from ByteDance AI products   · LOW

* Blocking a training crawler is a legitimate choice; lots of sites opt out and consider that fine. Blocking a live retrieval crawler is almost always an accident that destroys your AI visibility.

This distinction is the only AI crawler concept that actually matters. Everything else is footnotes. Two categories, opposite blast radius (and yes, the naming is genuinely awful — ChatGPT-User and GPTBot sound interchangeable, they aren’t):

  • Training crawlers (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended). They index your content for future model training. Opting out keeps you out of training data. Your live AI visibility doesn’t move.
  • Live-retrieval crawlers (ChatGPT-User, Claude-User, Perplexity-User). They fetch a page right now because a human asked a question that needs it. Blocking these is what makes you invisible in the answer.

Every “should I block AI?” debate that skips this distinction is wasted oxygen. You can opt out of training and still show up in answers. The configuration is just different.

The Cloudflare default block problem

In July 2024, Cloudflare shipped a one-click “Block AI Scrapers and Crawlers” toggle and turned it on by default for new free-plan zones. It blocks at the edge, runs before your origin sees the request, and bypasses robots.txt entirely.

This single setting is likely responsible for more silent AI invisibility in 2026 than every misconfigured robots.txt combined. Three things make it especially destructive:

  1. It’s on by default for many zones. Anyone who created a Cloudflare site in the last 18 months may have it enabled without knowing.
  2. Standard tools can’t see it. Cloudflare blocks before your origin runs. robots.txt is served by your origin. Parsers only see what the origin says — they’re talking to a server that has no idea the conversation happened.
  3. It blocks live retrieval too. It doesn’t distinguish training crawlers from live-retrieval ones. ChatGPT-User gets the same 403 as GPTBot.

Picture Cloudflare as a bouncer at the door. The bouncer checks the user-agent on the ID and decides whether to let the request through. Your robots.txt is a sign on the wall inside the building. The bouncer never reads it. The bot never gets close enough to.

Anyone running a Cloudflare zone created since mid-2024 who hasn’t checked their AI Audit settings should assume the bots are blocked until they’ve proven otherwise.

How to verify

Run the curl tests from the previous section. If you see server: cloudflare with a 403 on bot user-agents but a 200 on a regular browser UA, this is what’s happening:

# Browser UA — passes
$ curl -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" -I https://your-site.com
HTTP/2 200

# AI bot UA — blocked at the edge
$ curl -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" -I https://your-site.com
HTTP/2 403
server: cloudflare
cf-mitigated: challenge

To confirm in the dashboard: Cloudflare → Security → Bots → Configure. Look at the AI Audit / “Block AI Crawlers” toggle and the Super Bot Fight Mode settings.

How to fix

Three options, in increasing order of granularity:

1. Turn the global AI block off
   → Cloudflare → Security → Bots → uncheck "Block AI Scrapers"
   → Use this if you want to be discoverable in all AI surfaces.

2. Allow specific bots, block the rest
   → Cloudflare → Security → WAF → Create a custom rule
   → Match: http.user_agent contains "ChatGPT-User"
   → Action: Skip
   → Useful if you want to block training but allow live retrieval.

3. Use the verified-bot allowlist
   → Cloudflare maintains a list of verified AI bots that bypass blocks.
   → Settings → Bots → Verified Bots → review which AI categories you trust.

The same pattern shows up across edge providers. AWS WAF has managed rule groups that block AI bots by user-agent (AWS-AWSManagedRulesBotControlRuleSet). Fastly customers have written custom VCL to do the same. If you’re on any CDN, the question to ask is: is anything filtering by user-agent before my origin sees the request?

The JS-rendering trap

There’s a fourth way to be invisible that isn’t technically a block, and it’s worse because every diagnostic lies. Your server returns 200. Your headers look healthy. The bot fetches your page and walks away with nothing.

AI crawlers don’t run JavaScript. They read the initial HTML payload and stop. (Yes, Google’s been rendering JS for search since 2019. The AI crawlers haven’t caught up. They probably won’t soon.) If your site is a client-rendered SPA, the bot is reading an empty <body> and a div#root that never gets populated.

# Server returns 200, but the body is empty
$ curl -s -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" https://your-spa.com | wc -c
1247
# 1247 bytes — basically just the shell. The actual content is rendered by JS.

If you want to be more rigorous:

# Pipe the response through a text extractor and count actual content
$ curl -s -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" https://your-spa.com 
    | sed 's/<[^>]*>//g' | tr -s '[:space:]' ' ' | wc -w
14
# 14 words of content visible to GPTBot. The page actually has 1,200.

Or just open dev tools, disable JavaScript, and reload. If your content disappears, the AI crawlers are seeing the same blank page.

The fix is server-rendering or static generation. Astro, Remix, and SvelteKit default to SSR. The pattern that breaks is the pure SPA. CRA, Vite without SSR, anything that ships an empty index.html and hydrates from there.

Not a quick fix. But if you’ve ruled out robots.txt and edge blocks and you’re still invisible, this is probably what’s happening. The bot can’t see your content because there isn’t any to see.

The opt-outs that actually matter

Not every AI bot deserves the same answer. Treating “block AI” as a binary instead of a per-bot judgment call is how you end up either too permissive (free training data, no upside) or too restrictive (invisible in answers you wanted to be in). A short opinionated breakdown:

Always allow live-retrieval bots. ChatGPT-User, Claude-User, Perplexity-User. There is no downside. These fetch your page only when a human is actively asking a question that points at your content. Blocking them is a self-inflicted wound.

Allow training bots only if you want to be in training data. GPTBot, ClaudeBot, anthropic-ai. Opting out is a legitimate choice that more sites are making, especially publishers and SaaS companies who’d rather not have their docs used as gradient updates. Your live AI visibility isn’t affected either way.

Google-Extended is the complicated one. It controls Gemini and AI Overviews. Opting out keeps you out of those surfaces, which is increasingly costly as AI Overviews show up on a growing share of Google searches. Google-Extended is separate from Googlebot (disallowing the former has zero effect on your regular Google rankings). Lots of sites that thought they were opting out of “AI” tank their Gemini visibility and change nothing about their search traffic. Which is dumb.

Applebot-Extended, meta-externalagent, Bytespider are lower-stakes. These bots feed AI surfaces with much smaller market share. Decide on principle, not blast radius.

The framing that helps: every AI bot is either a customer (live retrieval, sends users back to you) or a vendor (training, builds models that may or may not link back to you). Most sites should let the customers in.

The 30-second version

Configuring robots.txt right in 2026 keeps you from being trivially invisible. It doesn’t make you visible. The failure mode killing AI visibility for most sites isn’t a missing robots.txt directive. It’s an edge-level block they didn’t know was on, or a JS-rendered page the crawler can’t read. If robots.txt is the only thing you test, you’re checking the layer where almost nothing actually goes wrong.

Test the live fetch. Run it under every bot UA you care about. And don’t just check the status code — read what came back. Your site might be one Cloudflare toggle away from being invisible to half the web in 2027 – and that toggle is already there, waiting.

Organizing How to Use AWS Glue Workflow

Original Japanese article: AWS Glue Workflowの使い方について整理してみる

Introduction

I’m Aki, an AWS Community Builder (@jitepengin).

Previously, I wrote an article comparing when to use AWS Step Functions versus Glue Workflow.
Organizing the Use Cases of AWS Step Functions and Glue Workflow for ETL Processing with AWS Glue Jobs

As I mentioned there, I personally like Glue Workflow and consider it an excellent service that balances simplicity and low cost.

However, in recent years, Step Functions has become increasingly mainstream, and I get the impression that opportunities to work with Glue Workflow have decreased.
Because of that, I think many people are unsure about how they should actually use it in practice.

So in this article, I’d like to organize everything from the basics of Glue Workflow to practical usage patterns.
I hope this helps more people become interested in Glue Workflow.

What Is Glue Workflow?

AWS Glue Workflow is Glue’s native workflow orchestration feature.
It defines ETL pipelines by combining the following three elements:

Component Role
Glue Job The actual ETL processing using Spark or Python Shell
Glue Crawler Scans data sources such as S3 and registers table definitions in the Data Catalog
Glue Trigger Defines execution conditions for Jobs and Crawlers (schedule, event, conditional, etc.)

By connecting these components as a DAG (Directed Acyclic Graph), you can build ETL pipelines.

Another characteristic is that workflows can be visually configured through the Glue console GUI.

A major advantage is that workflows themselves incur no additional cost—you only pay for Job and Crawler execution.

Details of the Core Components

The core of Glue Workflow is the Trigger mechanism.
There are four trigger types, each with different roles.

Trigger Type Execution Condition Typical Use Case
SCHEDULED Scheduled execution using cron expressions (UTC, minimum 5-minute interval) Periodic execution such as daily ETL
ON_DEMAND Manual execution or via API/SDK Arbitrary execution timing
CONDITIONAL Triggered based on the status of preceding Jobs/Crawlers Chained execution between Jobs
EVENT Triggered by EventBridge events Event-driven pipelines

The typical pattern is:

  • Use SCHEDULED, ON_DEMAND, or EVENT as the workflow’s starting trigger
  • Use CONDITIONAL to connect downstream Jobs

By the way, AWS officially recommends keeping the number of elements included in a workflow (Jobs + Crawlers + Triggers) under 100.
Exceeding this recommendation can cause errors when resuming or stopping Workflow Runs.

CONDITIONAL Trigger Usage Patterns

CONDITIONAL Triggers are one of the key features that provide flexibility in Glue Workflow.
Here are several representative patterns.

1. Simple Sequential Pattern

This is the most basic usage pattern: “Run JobB after JobA succeeds.”

JobA (SUCCEEDED) → JobB

You can implement this simply by configuring a CONDITIONAL Trigger with the condition:
“Start when JobA reaches the SUCCEEDED state.”

2. Waiting for Multiple Jobs to Complete (AND Condition)

This pattern runs JobC only after both JobA and JobB succeed.

JobA (SUCCEEDED) ┐
                 ├→ JobC
JobB (SUCCEEDED) ┘

This can be implemented by setting Logical: AND in the CONDITIONAL Trigger predicate and listing multiple conditions.

This is useful in scenarios such as:
“Run an aggregation Job only after multiple data sources have finished loading.”

3. Triggering When Any Job Completes (ANY Condition)

This pattern runs JobC when either JobA or JobB succeeds.

The predicate of a CONDITIONAL Trigger has a Logical field where you can specify either AND or ANY.
Using ANY causes the trigger to fire as soon as any one of the specified conditions is satisfied.

Note that although this behavior is logically equivalent to “OR,” the actual Glue configuration value is ANY.
This is important when defining workflows using IaC or CLI because specifying OR will result in an error.

4. Failure Branching (Catching FAILED States)

CONDITIONAL Triggers can react not only to SUCCEEDED, but also to states such as:

  • FAILED
  • STOPPED
  • TIMEOUT
  • ERROR

(The supported states differ slightly between Jobs and Crawlers.)

Using this feature, you can create patterns such as launching a notification Job (for example, a Python Shell Job that publishes to SNS) when a Job fails.

JobA (SUCCEEDED) → Downstream Processing
JobA (FAILED)    → Notification Job

For relatively simple error handling, this approach allows you to avoid introducing Step Functions.

Managing Parameters with default_run_properties

Glue Workflow provides a property called default_run_properties, which acts like globally shared variables across the workflow.

How It Works

default_run_properties stores key-value pairs that can be referenced by all Jobs within the workflow.

It functions as the default set of parameters passed during Workflow execution and serves as the foundation for sharing information between Jobs.

One important note:
Run Property values may appear in logs, so you should avoid storing secrets directly in them.

Instead, retrieve secrets through services such as:

  • AWS Secrets Manager
  • Glue Connections

How to Configure It

There are three main configuration methods.

Configure from the Console

In the Glue console:
Workflow → Edit Properties

You can add key-value pairs there.

Configure via boto3

import boto3

glue = boto3.client('glue')

glue.create_workflow(
    Name='my-workflow',
    DefaultRunProperties={
        'env': 'production',
        'target_date': '2026-05-21'
    }
)

Configure via IaC (CloudFormation, etc.)

You can specify DefaultRunProperties in the AWS::Glue::Workflow resource.

Static Parameters vs Dynamic Parameters

Typical usage patterns include:

  • Static parameters

    • Environment names (dev / prod)
    • S3 bucket names
    • Values that rarely change
  • Dynamic parameters

    • Processing dates
    • Execution IDs
    • Values that change per execution

Dynamic parameters can be updated either by:

  • Passing RunProperties to start_workflow_run
  • Dynamically updating them later using put_workflow_run_properties

Passing Data Between Jobs

Using default_run_properties as the foundation, let’s look at how to exchange data between Jobs.

Dynamically Updating Run Properties

During Job execution, you can dynamically update Workflow Run properties by calling the put_workflow_run_properties API.

import boto3

glue = boto3.client('glue')

glue.put_workflow_run_properties(
    Name='my-workflow',
    RunId=workflow_run_id,
    RunProperties={
        'processed_records': '12345',
        'output_path': 's3://mybucket/output/2026-05-21/'
    }
)

This allows downstream Jobs to reference values calculated by upstream Jobs.

Retrieving Properties from a PySpark Job

Inside a PySpark Job, you first retrieve the Workflow Run ID and then call get_workflow_run_properties.

import sys
import boto3
from awsglue.utils import getResolvedOptions

args = getResolvedOptions(
    sys.argv,
    ['JOB_NAME', 'WORKFLOW_NAME', 'WORKFLOW_RUN_ID']
)

glue = boto3.client('glue')

response = glue.get_workflow_run_properties(
    Name=args['WORKFLOW_NAME'],
    RunId=args['WORKFLOW_RUN_ID']
)

properties = response['RunProperties']

target_date = properties.get('target_date')

WORKFLOW_NAME and WORKFLOW_RUN_ID are special arguments automatically passed when a Job is launched through a Workflow.

Retrieving Properties from a Python Shell Job

The basic approach is the same for Python Shell Jobs:

  • Retrieve arguments using getResolvedOptions
  • Access properties through boto3

Since Python Shell Jobs do not require SparkContext initialization, they can be written more lightweight.

They are also cheaper than Spark-based Glue Jobs, so depending on the requirements, they can be a good option.

Personally, I also like Python Shell Jobs, although I feel opportunities to use them in real-world projects have decreased, which is a bit unfortunate.

I’ve also written articles about Python Shell Jobs, so feel free to check them out.

S3 Triggers: How to Launch Glue Python Shell via AWS Lambda

Anti-Patterns for Data Passing

default_run_properties should only be used for metadata-like information exchange.

The following usage patterns should generally be avoided:

  • Passing large datasets directly

    • Store the data itself in S3 and pass only the path
  • Storing secrets

    • Use Secrets Manager or Glue Connections instead
  • Frequently rewriting properties

    • This increases API calls and introduces race condition risks

Integrating with EventBridge and Other Services

Glue Workflow becomes much more flexible when combined with EventBridge.

EventBridge-Based Startup (EVENT Trigger)

Glue Workflow can be started directly by EventBridge events.

This is achieved by setting the Trigger Type to EVENT.

aws glue create-trigger 
  --workflow-name my-workflow 
  --type EVENT 
  --name s3-arrival-trigger 
  --actions JobName=my-job

By configuring an EventBridge rule with Glue Workflow as the target, the workflow starts automatically when an event occurs.

However, appropriate IAM permissions such as glue:notifyEvent are required.

Batch Event Startup

EVENT Triggers also support event batching.

Using EventBatchingCondition, you can configure the workflow to start when either:

  • N events arrive
  • M seconds pass since the first event arrived
aws glue create-trigger 
  --workflow-name my-workflow 
  --type EVENT 
  --name batch-trigger 
  --event-batching-condition BatchSize=10,BatchWindow=300 
  --actions JobName=my-job

This enables patterns such as:
“Run ETL once 100 files have arrived.”

The maximum batch window is 900 seconds (15 minutes).

Starting from S3 Events (The Parameter Passing Limitation)

A common use case is:
“Start a workflow when a file is uploaded to S3.”

When starting Glue Workflow through EventBridge, the event IDs are automatically stored in a Run Property called aws:eventIds.

event_ids = glue_client.get_workflow_run_properties(
    Name=workflow_name,
    RunId=workflow_run_id
)['RunProperties']['aws:eventIds']

The returned value looks like:

'["abc-123", "def-456"]'

However, this is where one of Glue Workflow’s limitations becomes apparent.

The EventBridge event payload itself (such as the S3 object key or bucket name) is not automatically passed as Run Properties.

Only the event IDs are provided.

If you need the actual object details, your Job must retrieve the corresponding event contents from CloudTrail, which becomes somewhat cumbersome.

Because of this, many cases are easier to manage by placing Lambda in the middle and explicitly calling start_workflow_run with structured Run Properties.

S3 PUT → EventBridge → Lambda → start_workflow_run (pass parameters via RunProperties)

Example Lambda code:

import boto3

glue = boto3.client('glue')

def lambda_handler(event, context):
    bucket = event['detail']['bucket']['name']
    key = event['detail']['object']['key']

    glue.start_workflow_run(
        Name='my-workflow',
        RunProperties={
            'source_bucket': bucket,
            'source_key': key
        }
    )

With Step Functions, the EventBridge payload can be received directly using paths such as $.detail, so there is no need for an intermediate Lambda function.

This is one of the areas where Glue Workflow limitations become noticeable compared to Step Functions.

Detecting Workflow Completion via EventBridge

Glue Workflow status changes are emitted as EventBridge events.

You can use this to:

  • Send SNS notifications
  • Trigger downstream systems
  • Launch post-processing workflows
Glue Workflow (COMPLETED/FAILED)
    → EventBridge
        → SNS / Lambda

This is especially useful when you want separate processing for success and failure cases.

Calling Glue Workflow from Step Functions

It is also possible to launch Glue Workflow from Step Functions.

However, since there is no .sync integration pattern available, you must implement your own polling logic to detect completion.

I covered this in detail in the previous article, so feel free to refer to it if interested.

Organizing the Use Cases of AWS Step Functions and Glue Workflow for ETL Processing with AWS Glue Jobs

Operational Tips

Here are several practical points worth knowing when operating Glue Workflow in production.

Resuming Failed Workflows (ResumeWorkflowRun)

Glue Workflow provides a feature called ResumeWorkflowRun, which allows resuming execution from failed nodes.

In the console, you can:

  • Open the failed Workflow Run detail page
  • Select the nodes to resume
  • Enable the “Resume” checkbox

It is also available through CLI/API.

aws glue resume-workflow-run 
  --name my-workflow 
  --run-id wr_xxxx 
  --node-ids node_yyyy node_zzzz

When resumed:

  • The specified nodes
  • And all downstream nodes

are re-executed.

The resumed workflow is tracked using a new Run ID.

Compared with Step Functions Redrive, however, there are several limitations:

  • You must explicitly specify failed nodes
  • Retrieving node IDs requires calling:
    get-workflow-run --include-graph
  • Additional IAM permissions (glue:ResumeWorkflowRun) are required

For simple retry scenarios, restarting the entire workflow via ON_DEMAND execution is often easier.

For pipelines with more sophisticated recovery requirements, Step Functions tends to provide a smoother operational experience.

Monitoring with CloudWatch

Glue Workflow execution states can be viewed in the Glue console, but detailed Job and Crawler logs are output to CloudWatch Logs.

Typical monitoring targets include:

  • Workflow Run list

    • Glue Console
  • Job execution logs

    • /aws-glue/jobs/output
    • /aws-glue/jobs/error
  • Metrics

    • CloudWatch Metrics
    • Execution duration
    • DPU usage

In practice, monitoring and alerting strategies often combine:

  • Workflow-level statuses
  • Job-level metrics

depending on the situation.

Handling the 100-Object Recommendation Limit

As mentioned earlier, AWS recommends keeping the total number of objects in a workflow (Jobs + Crawlers + Triggers) below 100.

If a large pipeline approaches this limit, consider:

  • Splitting workflows

    • Trigger downstream workflows after upstream completion
  • Consolidating common processing inside Jobs

    • Merge smaller processing units into larger Jobs

In my experience, workflows approaching 100 objects are often already too complex from a design perspective.
At that point, it may be worth reconsidering the architecture itself or migrating to Step Functions.

Where Glue Workflow Fits — and Its Limitations

Glue Workflow shines in scenarios such as:

  • Simple ETL pipelines completed entirely within Glue Jobs and Crawlers
  • Periodic ingestion pipelines for data lakes
  • Lightweight small-to-medium scale pipelines that need to be launched quickly

On the other hand, Step Functions is generally better suited for:

  • Integrations involving Lambda, ECS, and other AWS services
  • Complex branching and dynamic parameter control
  • Large-scale development involving multiple teams

I discussed these decision criteria in more detail in the previous article, so feel free to refer to it.

Organizing the Use Cases of AWS Step Functions and Glue Workflow for ETL Processing with AWS Glue Jobs

Conclusion

In this article, I organized AWS Glue Workflow from the basics through practical usage patterns.

To summarize:

  • Four trigger types:
    SCHEDULED / ON_DEMAND / CONDITIONAL / EVENT

  • CONDITIONAL Triggers:
    Flexible flow control using AND / ANY conditions and failure branching

  • default_run_properties:
    Shared workflow-wide parameter management

  • Data passing between Jobs:
    Dynamic value propagation using put_workflow_run_properties

  • EventBridge integration:
    Event-driven execution via EVENT Trigger
    (although Lambda-based parameter passing is often easier)

  • ResumeWorkflowRun:
    Partial restart functionality from failed nodes

After writing all of this, I still have to admit:
Step Functions is generally easier to use.

That said, Glue Workflow still provides meaningful value today because it allows you to build Glue-centric ETL pipelines in a very simple and cost-efficient way.

Rather than defaulting to Step Functions automatically, understanding Glue Workflow properly and knowing when to use it can broaden your architectural options.

I hope this article helps both people who are starting to use Glue Workflow and those already working with it.

5 n8n Automations Every Digital Agency Should Be Running (Bill More, Work Less)

Running a digital agency without automation is like doing client work with one hand tied behind your back. Every hour your team spends on repetitive tasks is an hour not spent on strategy, creative, or winning new clients.

I’ve run n8n automations for my agency clients for the past year. Here are the 5 that made the biggest difference — complete workflow JSON included so you can deploy in under 30 minutes.

Why n8n for Agencies?

  • Self-hosted = client data stays yours — no third-party seeing your client’s CRM data
  • Unlimited workflows on the self-hosted plan — no per-task fees eating your margins
  • White-label ready — you can install n8n on client infrastructure and bill for the setup
  • Complex logic — handle multi-step, conditional flows that Zapier/Make charge per operation

1. Client Onboarding Automation

The problem: New client signs → account manager scrambles to set up Slack channel, create Notion page, send welcome email, book kickoff call. Takes 45+ minutes, easy to miss steps.

The solution: Webhook trigger from your CRM/form → everything happens automatically.

What it does:

  • Receives webhook when new client is added (HubSpot, Pipedrive, or Typeform)
  • Creates a Slack channel #client-[company]
  • Creates a Notion project page from template
  • Sends branded welcome email with next steps
  • Posts alert to #new-clients Slack channel

Full workflow JSON:

{
  "name": "Client Onboarding Automation",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "new-client-onboard", "responseMode": "responseNode" },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "authentication": "oAuth2",
        "operation": "create",
        "channel": "={{ '#client-' + $json.body.company_name.toLowerCase().replace(/ /g, '-') }}",
        "options": {}
      },
      "name": "Create Slack Channel",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2,
      "position": [440, 200]
    },
    {
      "parameters": {
        "resource": "page",
        "operation": "create",
        "title": "={{ $json.body.company_name + ' — Project Hub' }}",
        "parentPageId": "YOUR_TEMPLATE_PAGE_ID"
      },
      "name": "Create Notion Page",
      "type": "n8n-nodes-base.notion",
      "typeVersion": 2,
      "position": [440, 380]
    },
    {
      "parameters": {
        "fromEmail": "[email protected]",
        "toEmail": "={{ $json.body.client_email }}",
        "subject": "Welcome to [Agency Name] — here's what happens next",
        "text": "Hi {{ $json.body.contact_name }},nnWelcome! We're thrilled to be working with {{ $json.body.company_name }}.nnHere's what happens next:n1. Your dedicated Slack channel is live — accept the inviten2. Kickoff call scheduled for Thursday at 2pmn3. Your project hub in Notion is readynnQuestions? Reply to this email.nnBest,n[Your Name]",
        "options": {}
      },
      "name": "Send Welcome Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [640, 300]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{"node": "Create Slack Channel", "type": "main", "index": 0}, {"node": "Create Notion Page", "type": "main", "index": 0}]] },
    "Create Slack Channel": { "main": [[{"node": "Send Welcome Email", "type": "main", "index": 0}]] }
  }
}

Result: Onboarding goes from 45-minute manual scramble to 30-second automatic deployment.

2. Weekly Client Performance Report

The problem: Every Friday, someone pulls GA4 data, Ads data, and social stats, formats a report, and emails 12 clients. Easily 4-6 hours per week.

The solution: Scheduled workflow that pulls data and emails formatted reports automatically.

What it does:

  • Runs every Friday at 8 AM
  • Pulls Google Analytics 4 data (sessions, conversions, revenue)
  • Pulls Google Ads metrics (spend, clicks, conversions, ROAS)
  • Formats a clean HTML report
  • Emails each client their own report with their own data

Full workflow JSON:

{
  "name": "Weekly Client Report",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 5" }] } },
      "name": "Every Friday 8AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getReport",
        "propertyId": "={{ $json.ga4_property_id }}",
        "dateRange": "last7DaysToday",
        "dimensions": [{ "name": "date" }],
        "metrics": [{ "name": "sessions" }, { "name": "conversions" }, { "name": "totalRevenue" }]
      },
      "name": "Get GA4 Data",
      "type": "n8n-nodes-base.googleAnalytics",
      "typeVersion": 1,
      "position": [440, 240]
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all(); const weekStr = new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); return items.map(item => { const d = item.json; const html = '<h2>Weekly Report — Week of ' + weekStr + '</h2><table border=1 cellpadding=8 style=border-collapse:collapse><tr><th>Metric</th><th>This Week</th></tr><tr><td>Sessions</td><td>' + (d.sessions || 0) + '</td></tr><tr><td>Conversions</td><td>' + (d.conversions || 0) + '</td></tr><tr><td>Revenue</td><td>$' + parseFloat(d.totalRevenue || 0).toFixed(2) + '</td></tr></table>'; return { json: { ...d, html_report: html } }; });"
      },
      "name": "Format Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "fromEmail": "[email protected]",
        "toEmail": "={{ $json.client_email }}",
        "subject": "Your Weekly Performance Report — {{ $now.format('MMMM D') }}",
        "html": "={{ $json.html_report }}",
        "options": {}
      },
      "name": "Email Report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [840, 300]
    }
  ],
  "connections": {
    "Every Friday 8AM": { "main": [[{ "node": "Get GA4 Data", "type": "main", "index": 0 }]] },
    "Get GA4 Data": { "main": [[{ "node": "Format Report", "type": "main", "index": 0 }]] },
    "Format Report": { "main": [[{ "node": "Email Report", "type": "main", "index": 0 }]] }
  }
}

Result: 6 hours of Friday reporting → 0 hours. Clients get reports before they ask for them.

3. Lead Nurturing Pipeline

The problem: New inquiry comes in from website form. Someone should follow up within 5 minutes, send useful content over 3 days, and notify sales when the lead is warm. In practice, leads slip through cracks constantly.

The solution: Webhook from contact form → instant response + automated nurture sequence.

What it does:

  • Webhook catches form submission (Typeform, Gravity Forms, HubSpot)
  • Instantly sends personalized response email within 60 seconds
  • Adds lead to CRM (HubSpot/Airtable)
  • Sends follow-up email at Day 1, Day 3, Day 7
  • Pings Slack when lead opens Day 3 email (warm signal)

Full workflow JSON:

{
  "name": "Agency Lead Nurture Pipeline",
  "nodes": [
    {
      "parameters": { "httpMethod": "POST", "path": "new-lead", "responseMode": "responseNode" },
      "name": "New Lead Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "fromEmail": "[email protected]",
        "toEmail": "={{ $json.body.email }}",
        "subject": "Got your message — here's what happens next",
        "text": "Hi {{ $json.body.first_name }},nnThanks for reaching out. I've read your message about {{ $json.body.project_type }}.nnI'll have a full response within 24 hours. In the meantime, here are 3 things most clients in your situation wish they knew earlier: [link to resource]nnTalk soon,n[Your Name]",
        "options": {}
      },
      "name": "Instant Response Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [440, 200]
    },
    {
      "parameters": {
        "resource": "contact",
        "operation": "upsert",
        "email": "={{ $json.body.email }}",
        "additionalFields": {
          "firstname": "={{ $json.body.first_name }}",
          "lastname": "={{ $json.body.last_name }}",
          "lead_source": "Website Form",
          "hs_lead_status": "NEW"
        }
      },
      "name": "Add to HubSpot",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2,
      "position": [440, 400]
    },
    {
      "parameters": {
        "channel": "#new-leads",
        "text": "New lead: {{ $json.body.first_name }} {{ $json.body.last_name }} ({{ $json.body.email }}) — {{ $json.body.project_type }}. Budget: {{ $json.body.budget || 'not specified' }}.",
        "options": {}
      },
      "name": "Slack Alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2,
      "position": [640, 300]
    }
  ],
  "connections": {
    "New Lead Webhook": { "main": [[{ "node": "Instant Response Email", "type": "main", "index": 0 }, { "node": "Add to HubSpot", "type": "main", "index": 0 }]] },
    "Instant Response Email": { "main": [[{ "node": "Slack Alert", "type": "main", "index": 0 }]] }
  }
}

Result: Every lead gets an instant personal-feeling response. You’re responsive while the workflow handles the grunt work.

4. Social Media Content Scheduler (Multi-Client)

The problem: You manage social media for 6 clients. Scheduling posts across Twitter, LinkedIn, Instagram, and Facebook for all of them takes your whole Monday morning.

The solution: One spreadsheet per client. Workflow reads it, posts on schedule, marks as posted.

What it does:

  • Reads Google Sheet with columns: Date, Time, Platform, Content, Image URL, Client
  • Filters for posts scheduled within the next hour
  • Posts to correct platform (Twitter/LinkedIn/Facebook/Instagram)
  • Updates sheet to mark post as “Posted” with timestamp
  • Sends Slack summary of what was posted

Full workflow JSON:

{
  "name": "Multi-Client Social Scheduler",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "hours", "hoursInterval": 1 }] } },
      "name": "Every Hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getAll",
        "sheetId": "YOUR_SHEET_ID",
        "range": "Posts!A:H",
        "options": { "firstRowAsHeader": true }
      },
      "name": "Read Content Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [440, 300]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            { "leftValue": "={{ $json.Status }}", "rightValue": "Scheduled", "operator": { "type": "string", "operation": "equals" } }
          ]
        }
      },
      "name": "Filter Pending Posts",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "jsCode": "const now = new Date(); const oneHour = 60 * 60 * 1000; return $input.all().filter(item => { const postTime = new Date(item.json.Date + ' ' + item.json.Time); const diff = postTime - now; return diff >= 0 && diff <= oneHour; });"
      },
      "name": "Filter Due This Hour",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [840, 300]
    }
  ],
  "connections": {
    "Every Hour": { "main": [[{ "node": "Read Content Sheet", "type": "main", "index": 0 }]] },
    "Read Content Sheet": { "main": [[{ "node": "Filter Pending Posts", "type": "main", "index": 0 }]] },
    "Filter Pending Posts": { "main": [[{ "node": "Filter Due This Hour", "type": "main", "index": 0 }]] }
  }
}

Result: Social media management goes from manual Monday scheduling to a background task. Add clients by adding rows to a sheet.

5. Invoice Generation + Payment Follow-Up

The problem: End of month, you’re generating 20 invoices manually, tracking who paid, sending polite “just checking in” emails to late payers. Takes hours. Awkward.

The solution: n8n pulls project data from your sheet, generates invoices, sends them, and follows up automatically if unpaid after 7 days.

What it does:

  • Runs on the 1st of each month at 9 AM
  • Reads client project data from Google Sheets (client name, email, services, rate, hours)
  • Generates professional HTML invoice with unique invoice number
  • Emails invoice as HTML (or PDF via a PDF API)
  • After 7 days, checks if payment received — if not, sends polite reminder
  • After 14 days, sends firmer follow-up + Slack alert to account manager

Full workflow JSON:

{
  "name": "Invoice Generator + Follow-Up",
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 1 * *" }] } },
      "name": "1st of Month 9AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "getAll",
        "sheetId": "YOUR_BILLING_SHEET_ID",
        "range": "Clients!A:G",
        "options": { "firstRowAsHeader": true }
      },
      "name": "Read Client Billing Data",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [440, 300]
    },
    {
      "parameters": {
        "jsCode": "const month = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); return $input.all().map(item => { const d = item.json; const amount = parseFloat(d.HourlyRate || 0) * parseFloat(d.HoursThisMonth || 0); const invoiceNum = 'INV-' + Date.now().toString().slice(-6); const html = '<!DOCTYPE html><html><body style=font-family:Arial,sans-serif;max-width:600px;margin:0 auto><h1 style=color:#1a1a2e>INVOICE ' + invoiceNum + '</h1><p><strong>To:</strong> ' + d.ClientName + '</p><p><strong>For:</strong> ' + d.Services + ' — ' + month + '</p><table border=1 cellpadding=8 style=border-collapse:collapse;width:100%><tr><th>Description</th><th>Hours</th><th>Rate</th><th>Total</th></tr><tr><td>' + d.Services + '</td><td>' + d.HoursThisMonth + '</td><td>$' + d.HourlyRate + '/hr</td><td>$' + amount.toFixed(2) + '</td></tr></table><h2>Total Due: $' + amount.toFixed(2) + '</h2><p>Payment due within 14 days. Bank transfer or PayPal.</p></body></html>'; return { json: { ...d, invoice_num: invoiceNum, amount: amount, month, html_invoice: html } }; });"
      },
      "name": "Generate Invoice HTML",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "fromEmail": "[email protected]",
        "toEmail": "={{ $json.ClientEmail }}",
        "subject": "Invoice {{ $json.invoice_num }} — {{ $json.month }} — ${{ $json.amount }}",
        "html": "={{ $json.html_invoice }}",
        "options": {}
      },
      "name": "Send Invoice",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [840, 300]
    }
  ],
  "connections": {
    "1st of Month 9AM": { "main": [[{ "node": "Read Client Billing Data", "type": "main", "index": 0 }]] },
    "Read Client Billing Data": { "main": [[{ "node": "Generate Invoice HTML", "type": "main", "index": 0 }]] },
    "Generate Invoice HTML": { "main": [[{ "node": "Send Invoice", "type": "main", "index": 0 }]] },
    "Send Invoice": {}
  }
}

Result: Month-end billing goes from 4-hour manual task to 0 hours. Awkward “did you pay yet?” emails get sent by a robot, not you.

The Agency Automation Stack

These 5 workflows together save roughly 8-12 hours per week for a small agency. At a $150/hour billing rate, that’s $1,200-1,800 per week in recovered time.

Workflow Time Saved/Week Setup Time
Client Onboarding 3-4 hours 45 min
Weekly Reports 4-6 hours 60 min
Lead Nurturing 2-3 hours 30 min
Social Scheduling 3-4 hours 45 min
Invoice + Follow-Up 2-3 hours 30 min

Want the Pre-Built Versions?

Setting up n8n workflows from scratch takes time. I’ve packaged production-ready versions of these and more (with error handling, logging, and retry logic) at FlowKit — n8n Templates for Business.

Individual templates from $12. Full bundle (15 templates) at $97.

Comments? Questions?

Drop them below. If you’re running an agency and have a specific workflow problem, I’d love to hear it — might become the next article.

Getting Started with TorchGeo — Remote Sensing with PyTorch

torchvision is great for natural images. But remote sensing data is different:

  • GeoTIFFs, not PNGs — with coordinate reference systems baked in
  • Multi-spectral bands — beyond RGB into near-infrared, thermal, SAR
  • Massive sizes — a single satellite image can be 10,000×10,000 pixels
  • Spatial context matters — random cropping destroys geographic patterns

TorchGeo is PyTorch’s official geospatial extension by Microsoft. It provides 50+ remote sensing datasets (one-line download), geo-aware samplers, and seamless integration with torchvision and PyTorch Lightning.

pip install torchgeo rasterio

Loading Your First Dataset

Let’s start with EuroSAT — 27,000 Sentinel-2 satellite images across 10 land cover classes:

from torchgeo.datasets import EuroSAT

dataset = EuroSAT(root="./data", download=True)
print(len(dataset))          # 27000
print(dataset.num_classes)   # 10
print(dataset.classes)
# ['AnnualCrop', 'Forest', 'HerbaceousVegetation',
#  'Highway', 'Industrial', 'Pasture', 'PermanentCrop',
#  'Residential', 'River', 'SeaLake']

Each sample is a dict with image (multi-spectral tensor) and label (integer):

sample = dataset[0]
print(sample['image'].shape)  # torch.Size([13, 64, 64]) — 13 Sentinel-2 bands
print(sample['label'])        # 0 → AnnualCrop

Building the Data Pipeline

Remote sensing datasets return dicts, so we need a custom collate_fn:

from torch.utils.data import DataLoader
from torchvision import transforms

transform = transforms.Compose([
    transforms.Resize((64, 64)),
    transforms.Lambda(lambda x: x.float() / 255.0),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225])
])

def collate_fn(batch):
    images = torch.stack([transform(b['image'][:3]) for b in batch])
    labels = torch.tensor([b['label'] for b in batch])
    return images, labels

loader = DataLoader(dataset, batch_size=32, collate_fn=collate_fn)

For RGB display, we take image[:3] (B04, B03, B02). For multi-spectral analysis, keep all 13 bands.

Transfer Learning with ResNet18

Replace the final fully-connected layer for our 10 classes:

from torchvision.models import resnet18
import torch.nn as nn

model = resnet18(weights='IMAGENET1K_V1')
model.fc = nn.Linear(512, 10)

With 3 epochs and ImageNet pretrained weights on GPU (RTX 4060, 8GB), this reaches 97.8% training accuracy and 83.7% test accuracy in just 40 seconds:

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

for epoch in range(3):
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

Key Datasets at a Glance

Dataset Task Size Classes
RESISC45 Scene classification 31,500 45
UCMerced Land use 2,100 21
LandCoverAI Land cover 10,674 5
BigEarthNet Multi-label 590k 43

Where to Go Next

  1. Geo-aware sampling — RandomGeoSampler for tiling massive GeoTIFFs
  2. Pre-trained remote sensing models — TorchGeo ships weights pretrained on BigEarthNet
  3. Semantic segmentation — LandCoverAI + DeepLabV3 for pixel-level classification
  4. Multi-spectral processing — Work with all 13 Sentinel-2 bands
  5. Change detection — Compare satellite images across time

Official docs: docs.torchgeo.org

References