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

Designing a Scalable Cross-Platform Appium Framework

  • Designing the cross-platform architecture you can maintain
  • Applying the Page Object Model without creating accidental complexity
  • Making parallel execution predictable: sharding, ports, and device farms
  • CI/CD mobile testing: pipeline patterns that actually run reliably
  • Monitoring, metrics, and policies for long-term maintenance
  • Practical Application: checklists, templates, and example configs

Cross-platform mobile automation often breaks not because Appium can’t drive devices, but because teams build frameworks that duplicate screen logic, hide driver complexity, and treat device management as a low-priority ops task. A pragmatic, layered Appium framework — built around a disciplined page object model, deterministic parallel execution, and CI-driven device orchestration — turns brittle evidence of quality into reliable, fast feedback.

Your test suite is noisy: intermittent failures that aren’t product bugs, a stack of duplicated locators across Android and iOS, and runs that serially consume hours. That noise causes two predictable outcomes in professional teams: developers stop trusting UI tests, and QA spends a majority of time on infra triage instead of improving coverage. These symptoms require design-level fixes — not more flaky retries.

Designing the cross-platform architecture you can maintain

A maintainable cross-platform Appium framework separates concerns into clear layers and keeps platform-specific differences localized.

  • Architecture layers (minimal and pragmatic):
    • Test runner layer — tests and assertions (e.g., TestNG, Pytest). Tests should reference page services, not raw element locators.
    • Orchestration / runner utilities — DriverFactory, capability loaders, session lifecycle hooks, retries/quarantine helpers.
    • Screen/Page objects — LoginPage, HomePage (use component objects for reusable widgets).
    • Platform adapters — small classes that encapsulate platform differences (e.g., AndroidActions, IOSActions).
    • Infra / device layer — device provisioning, Appium server/process management, cloud connectors (BrowserStack/Sauce/AWS/etc).
    • Reports & artifacts — structured attachments, screenshots, logs, Allure/HTML adapters.

Design rules I use on teams:

  • Keep driver creation explicit and test-friendly: a DriverFactory returns an AppiumDriver configured from capabilities.json or environment variables; tests never construct capabilities inline.
  • Prefer composition over inheritance for pages: compose pages from small component objects (cards, nav bars).
  • Centralize test data and environment toggles in a single config artifact (config.json, capabilities.yml) to keep capability churn visible and reviewable.

Example: a terse Java-style BasePage + LoginPage (using Appium PageFactory patterns).

// BasePage.java
public abstract class BasePage {
    protected final AppiumDriver driver;
    public BasePage(AppiumDriver driver) { this.driver = driver; }
    protected void waitForVisible(By locator) {
        new WebDriverWait(driver, Duration.ofSeconds(10)).until(ExpectedConditions.visibilityOfElementLocated(locator));
    }
}

// LoginPage.java
public class LoginPage extends BasePage {
    @AndroidFindBy(accessibility = "login_email")
    @iOSXCUITFindBy(accessibility = "login_email")
    private MobileElement emailField;

    @AndroidFindBy(accessibility = "login_submit")
    @iOSXCUITFindBy(accessibility = "login_submit")
    private MobileElement submitButton;

    public LoginPage(AppiumDriver driver) {
        super(driver);
        PageFactory.initElements(new AppiumFieldDecorator(driver, Duration.ofSeconds(5)), this);
    }

    public HomePage login(String user, String pass) {
        emailField.sendKeys(user);
        // password + submit ...
        submitButton.click();
        return new HomePage(driver);
    }
}

Use Appium’s Java client PageFactory features and find-by annotations to keep locators co-located with behavior. The Java client provides AppiumFieldDecorator and platform-specific annotations like @AndroidFindBy and @iOSXCUITFindBy.

Important: keep assertions out of page objects; page objects are services the test uses, not validators. Encapsulate simple “loaded” checks in constructors or isLoaded() helpers, but put the expectations in the test.

Applying the Page Object Model without creating accidental complexity

POM is an enabler, not an end state. I see two common mistakes that cause POM to fail at scale: (1) creating a huge base page with dozens of unrelated helpers and (2) copying separate page classes for Android and iOS that duplicate logic.

Practical guidance:

  • Use component objects for repeating UI pieces (lists, cards, bottom sheets). They are small, testable units referenced by pages.
  • Use platform-specific locators only where necessary. Prefer shared accessibility IDs and content-desc so a single locator can work on both platforms.
  • Keep each page object focused: 10–20 methods max. If a page grows larger, split it into multiple components.
  • Avoid premature abstraction. In small MVPs, the mental overhead of POM can be counterproductive; scale POM incrementally as your test count grows. This contrarian view is shared by practitioners who opt for flatter scripts for tiny projects.

A healthy pattern: pages implement services (e.g., loginAs(user)), tests orchestrate scenarios, and any platform-specific differences live in tiny adapter classes.

Making parallel execution predictable: sharding, ports, and device farms

Parallel runs scale the wall-clock speed of your suite, but they add infrastructure complexity. You need deterministic session configuration and a strategy for where tests run.

Key platform details:

  • Each parallel Appium session that touches a real device or simulator often requires unique platform-specific ports/capabilities: udid, systemPort and chromedriverPort for Android uiautomator2-based sessions; wdaLocalPort, derivedDataPath for iOS XCUITest sessions. Appium documents these as the standard way to avoid port conflicts and resource contention.
  • For larger scale, run multiple Appium server instances (one per host or per device), and use Selenium Grid 4+ relay or a device farm provider to route sessions through a single hub endpoint. Appium+Grid integration is a supported archetype.

Sharding strategies:

  • Shard by test class or by logical group (smoke, critical flows). For deterministic parallelism use test-runner features (TestNG parallel="tests" or xdist pytest -n) to control granularity.
  • Prefer deterministic sharding (fixed mapping) for critical flows and dynamic sharding for broad regression matrices.

TestNG example (run Android and iOS tests in parallel):

<suite name="MobileSuite" parallel="tests" thread-count="4">
  <test name="AndroidRegression">
    <parameter name="platform" value="Android"/>
    <classes>
      <class name="tests.android.LoginTests"/>
    </classes>
  </test>
  <test name="iOSRegression">
    <parameter name="platform" value="iOS"/>
    <classes>
      <class name="tests.ios.LoginTests"/>
    </classes>
  </test>
</suite>

Device management choices (comparison):

Approach Pros Cons Best for
Local device lab Full control; low per-test cost after capex Setup/maintenance, device churn, limited concurrency Deep debugging, preflight instrumentation
Cloud device farm (Sauce/BrowserStack) Massive coverage, easy parallelism, API-driven allocation Recurring cost, possible queuing/availability Large matrix, CI-driven nightly/regression runs
Managed services (Firebase/AWS Device Farm) Tight CI integrations, artifact storage May not support all tooling patterns (e.g., some Appium flavors) Android-focused device coverage, integration with Google infra

Cloud providers expose features that make parallel runs predictable: dynamic device allocation, device caching options, and run artifact storage. Sauce Labs, BrowserStack, Firebase, and AWS Device Farm document these device orchestration patterns and how to pass credentials and app artifacts.

Operational tactics that reduce flakiness during parallel runs:

  • Always set unique systemPort/wdaLocalPort per session when running multiple sessions per host.
  • Make tests idempotent: avoid shared state between tests on a device; use noReset only if the test account/state is intentionally reusable and consistent.
  • Build a short smoke shard that runs on each PR against a single device family to catch obvious regression before running large matrices.

CI/CD mobile testing: pipeline patterns that actually run reliably

Treat the app build artifact as the single source of truth for the pipeline. Your pipeline stages should be explicit, observable, and cached.

A typical pipeline flow:

  1. Build and sign artifacts (Android .apk/.aab, iOS .ipa) using Gradle and xcodebuild orchestrated with fastlane for reproducible signing and distribution.
  2. Upload artifacts to an artifact store or device-farm app storage (e.g., Sauce/app storage, BrowserStack/App Automate, AWS Device Farm).
  3. Trigger small smoke tests on a single device emulator/simulator in the same pipeline job to validate the build.
  4. Trigger matrix runs (parallel) either on cloud device farms or agent pools. Capture logs, videos, and crash reports as artifacts.
  5. Publish results to a report server (Allure, or stored HTML) and gate deployments on low flakiness and passing smoke tests.

Example Jenkinsfile snippet (conceptual):

pipeline {
  agent any
  environment { APP_ARTIFACT = 'build/outputs/apk/debug/app-debug.apk' }
  stages {
    stage('Build') { steps { sh './gradlew assembleDebug' } }
    stage('Sign & Upload') { steps { sh 'fastlane beta' } } // builds .ipa/.apk and uploads
    stage('Smoke') { steps { sh "mvn -Dtest=SmokeTests test" } }
    stage('Parallel Matrix') {
      steps {
        // Or call cloud provider API / trigger device-farm job
        sh 'python ci/schedule_devicefarm_run.py --matrix matrix.json'
      }
    }
  }
  post { always { archiveArtifacts artifacts: 'reports/**' } }
}

If you use hosted CI (GitLab CI, GitHub Actions), integrate device-farm actions/plugins (AWS Device Farm action, BrowserStack plugin, Sauce bindings) to keep secrets and orchestration declarative and auditable.

Practical notes:

  • Use fastlane for consistent Xcode/Android signing and build steps; put code signing logic behind lanes so pipelines remain readable and reproducible.
  • Keep secrets (keys, certificates) in the CI secret store and avoid committing provisioning artifacts to repo.

Monitoring, metrics, and policies for long-term maintenance

Instrumentation and measurement are where automation pays off or becomes a liability. Track a compact set of KPIs and make them visible.

Essential metrics:

  • Flakiness rate — percent of test runs that fail intermittently on unchanged code. Track this per-test and per-run. Use statistical approaches (like impact scoring) to prioritize fixes. Research into flaky tests highlights the need to measure and isolate flaky tests rather than ignore them.
  • Test duration / suite runtime — average and 95th percentile; target reductions via sharding and smarter selection.
  • Infra failure rate — device allocation failures, Appium session errors; if infra failures dominate, investment in device orchestration is warranted.
  • Coverage of critical flows — percentage of critical user journeys covered by deterministic, low-flakiness tests.

Reporting and tooling:

  • Use a framework-agnostic report generator (Allure) to collect attachments (screenshots, logs, video) and visualize test history and stability across runs. Allure supports test history and stability charts that become valuable in quarterly reviews.
  • Feed CI events and run durations to a time-series store or CI analytics tool (Prometheus + Grafana or commercial CI analytics) to spot regressions in execution time or infra reliability.

Operational policy examples (codify these):

  • Quarantine tests with > X% flakiness for triage and avoid blocking releases until fixed; prioritize by impact score. Measure flakiness trends, not single failures.
  • Keep artifact retention rules: store logs/screenshots for failed runs for 30–90 days depending on compliance needs.
  • Schedule periodic cleanup: every quarter review device matrix to drop OS versions with negligible user share and add recent devices based on telemetry.

Callout: Treat automation as product code: apply PR reviews, CLA, and release notes for framework changes. Instrument the framework itself (test runtime, number of retries, flaky tests flagged) so the team treats the test suite as a first-class deliverable.

Practical Application: checklists, templates, and example configs

Below are actionable templates and checklists you can copy into your repo to bootstrap or refactor a framework quickly.

Minimal acceptance checklist (initial sprint)

  • [ ] Create DriverFactory that reads capabilities.json and environment variables.
  • [ ] Implement 10 critical end-to-end flows as POMs (smoke tests).
  • [ ] Add a single PR-driven smoke job (one device/emulator) in CI.
  • [ ] Add a nightly matrix job on a cloud device farm with parallel shards.
  • [ ] Wire Allure (or equivalent) and keep artifacts for failed runs.

Sample capabilities.json (snippet)

{
  "android_pixel_11": {
    "platformName": "Android",
    "deviceName": "Google Pixel 5",
    "platformVersion": "11.0",
    "udid": "emulator-5554",
    "appium:systemPort": 8200,
    "appium:automationName": "UiAutomator2"
  },
  "ios_iphone_14": {
    "platformName": "iOS",
    "deviceName": "iPhone 14",
    "platformVersion": "16.0",
    "udid": "<device-udid>",
    "appium:wdaLocalPort": 8101,
    "appium:automationName": "XCUITest"
  }
}

Java DriverFactory sketch (concept)

public class DriverFactory {
  public static AppiumDriver createDriver(Map<String,Object> caps) throws MalformedURLException {
    MutableCapabilities options = new MutableCapabilities();
    options.merge(new DesiredCapabilities(caps));
    String hub = System.getenv().getOrDefault("APPIUM_SERVER", "http://localhost:4723/wd/hub");
    return new AppiumDriver(new URL(hub), options);
  }
}

Example Jenkinsfile snippet to schedule AWS Device Farm (conceptual; use action/plugin in your platform):

stage('Schedule Device Farm') {
  steps {
    sh 'aws devicefarm create-upload --project-arn $PROJECT_ARN --name app-debug.apk --type ANDROID_APP --cli-binary'
    sh 'aws devicefarm schedule-run --project-arn $PROJECT_ARN --app-arn $APP_ARN --device-pool-arn $POOL_ARN --test type=APPIUM_NODE,testPackageArn=$TEST_ARN'
  }
}

Test sharding checklist

  • Shard by test-suite or feature to minimize inter-test dependencies.
  • Keep shards repeatable: fix random order failures before parallelizing.
  • Use minimal timeouts on UI waits for smoke, longer for full regression.

Quarantine policy template (put in docs/quarantine.md)

  • Criteria to quarantine: a test fails intermittently on at least three runs across three distinct commits/branches.
  • Quarantine steps: mark test with @quarantine, stop auto-retries, add Jira ticket with impact score.

Artifacts & retention

  • Keep logs + screenshots for failed runs for at least 30 days.
  • Keep videos for high-priority regression failures for 90 days.

Closing paragraph

Build the layers once, measure what matters (flakiness and infra failures), and make the framework part of delivery rather than an afterthought; that discipline turns mobile automation from a risky cost center into a measurable accelerator for quality and speed.

Sources:
Appium — Intro to Development – Appium v2 modular architecture and guidance on drivers/plugins; used for design patterns, Appium capabilities model, and the cross-platform rationale.

Selenium — Page Object Models – Recommended POM practices and guidance on component/page responsibilities (e.g., avoid assertions in page objects).

Appium XCUITest Driver — Testing in Parallel – Details on wdaLocalPort, derivedDataPath, and iOS parallel execution specifics.

Appium and Selenium Grid Guide – How to register Appium servers with Selenium Grid and relay traffic for larger grids.

Sauce Labs — Appium Testing with Real Devices – Device allocation, cacheId, and cloud device orchestration features.

BrowserStack — Parallel Appium Tests Guide – Parallelization patterns and practical notes on reducing wall-clock time with cloud parallel runs.

Firebase Test Lab — Overview & How it Works – Test matrix runs, real/virtual device coverage, CI integration notes.

Fastlane — App Store Deployment and build actions – Using fastlane for reproducible iOS builds, signing and lanes; useful for CI build steps.

GitLab — Mobile DevOps iOS CI/CD Tutorial – Example pipeline and patterns for building and distributing mobile artifacts in CI.

AWS Device Farm GitHub Action (aws-actions) – Example GitHub Action usage and JSON run-spec for scheduling Appium runs on AWS Device Farm.

Appium Java Client — AppiumFieldDecorator & PageFactory API – PageFactory integration, @AndroidFindBy / @iOSXCUITFindBy and the decorator patterns for Appium Java clients.

Test flakiness review (multivocal review) – Academic review on causes, detection, and management strategies for flaky tests; used for flakiness treatment rationale.

Allure Report Documentation – How Allure collects history, attachments, and stability metrics useful for test reporting in CI.

BrowserStack — Integrate your Appium test suite with Jenkins – CI plugin integration patterns and credential handling for Jenkins.

Why I Don’t Use Page Object Model in Small Mobile Automation Projects (Medium) – Practitioner perspective advocating simpler scripts for very small projects; used to explain when POM can be counterproductive.

Google Antigravity 2.0 & Slash Commands

TL;DR Summary:
Google has revolutionized AI-assisted development with Antigravity 2.0, an agent-first architecture replacing traditional code editors. Key updates include powerful slash commands for autonomous workflow management, the new Android CLI for direct IDE integration (“Vibe Coding”), Firebase Agent Skills for automated backend logic, and WebMCP/AppFunctions for seamless app-to-app data manipulation powered by Gemini.

Google pulled a massive surprise with Antigravity 2.0 by completely overhauling the app into an agent-first architecture. They stripped away the traditional code editor and replaced it with an advanced agent manager designed to run multiple AI agents across multiple projects concurrently.

Antigravity Slash Commands for Workflow Automation

Antigravity introduced powerful slash commands to assist and automate your developer workflow:

  • /goal – Instructs an AI agent to run autonomously until a specified complex task is complete, removing the need for intermediate human approval.
  • /schedule – Handles cron-based, recurring agent execution for automated maintenance.
  • /browser – Hands explicit browser navigation and automated UI testing control directly to the agent.
  • /grill-me – Forces the agent to aggressively cross-examine you with clarifying questions, ensuring complete alignment before writing a single line of code.

The Android CLI & “Vibe Coding”

Google just handed developers a massive productivity upgrade with the announcement of the Android CLI. This new Command Line Interface grants AI coding agents like Claude Code, Codex, or Antigravity direct access to your local development environment and Android Studio. The live demo showcased AI agents autonomously writing code, making edits, and executing tests directly on the Android emulator.

Furthermore, Google AI Studio is expanding into native prompt-based Android development—a paradigm shift we call Vibe Coding. Developers can design, prototype, and build completely functional native Android applications directly inside AI Studio and immediately export them directly to their physical devices.

Agent Skills for Firebase & Migration Assistant

Google also introduced Agent Skills for Firebase and a powerful new Migration Assistant. Agent Skills empower developers to write complex backend logic, cloud functions, and database rules for Firebase and Firestore directly inside the IDE via natural language. As a developer, this is a massive time-saver; you can hand off writing security rules to Antigravity, and the AI agent handles the rest securely.

Tackling cross-platform development headaches, Google’s new Migration Assistant is specifically designed to transition legacy iOS, React Native, or web framework codebases over to modern, native Android seamlessly.

Re-Engineering the Web & Android 17 Intelligence Layer

Google is reshaping how agents interact with the web and operating systems:

  • WebMCP (Model Context Protocol): An open standard designed to let developers expose HTML forms and JavaScript functions directly to AI models, bridging the gap between web interfaces and LLMs.
  • Chrome DevTools for Agents: Allows AI agents to autonomously debug, profile, and resolve complex issues within web applications.
  • AppFunctions API: Turns installed Android apps into on-device Model Context Protocol (MCP) servers. This groundbreaking API allows Gemini to autonomously pull data from one application, manipulate it, and inject it into another with absolutely zero user intervention.

Building a Unified Adaptive Learning Intelligence with Gemma 4, Flutter, and Multi-Model Orchestration

*This is a submission for the [Google I/O WritiHow Gemma 4 Became the Cognitive Core of a Cinematic AI Tutoring System

Building a Unified Adaptive Learning Intelligence with Gemma 4, Flutter, and Multi-Model Orchestration

AI tutors are everywhere now.

But most still feel like disconnected chatbot wrappers.

They answer questions, generate summaries, and explain concepts reasonably well — yet something still feels missing:

  • broken continuity
  • shallow personalization
  • inconsistent teaching styles
  • fragmented reasoning
  • robotic interaction loops

After watching the announcements and sessions from Google I/O 2026, I realized the real breakthrough wasn’t just about larger models or faster inference.

It was something much bigger.

The future of AI applications is shifting toward orchestrated intelligence systems — experiences where multiple AI components work together invisibly to create something coherent, adaptive, and deeply contextual.

That realization completely reshaped how I approached my own project:

«Gemma Mentor AI — a cinematic adaptive tutoring platform built around multi-model orchestration, semantic intelligence routing, and immersive learning experiences.»

And at the center of that system was one model that changed the way I thought about deployable AI architecture:

Gemma 4.

The Problem With Most AI Tutors

Most AI tutoring systems today are still designed like upgraded chat interfaces.

They usually work like this:

  1. User asks a question
  2. Model generates response
  3. Context grows until it breaks
  4. Conversation quality slowly degrades

The result is an experience that often feels:

  • reactive instead of adaptive
  • informative instead of educational
  • intelligent but not coherent

Real tutoring is different.

A good tutor:

  • remembers learning patterns
  • adapts pacing
  • maintains emotional continuity
  • shifts explanation styles dynamically
  • understands confusion before the learner fully articulates it

That requires far more than a single prompt-response loop.

It requires orchestration.

The Moment Google I/O 2026 Changed My Perspective

While exploring the announcements from Google I/O 2026 and the broader Google AI ecosystem direction, one thing became increasingly clear:

AI development is evolving beyond isolated models.

What stood out to me most was the ecosystem philosophy emerging around:

  • deployable intelligence
  • multimodal systems
  • developer accessibility
  • scalable AI tooling
  • lightweight but capable models
  • orchestration-ready architectures

That was especially true with the growing ecosystem around Gemma 4.

Instead of viewing models as standalone products, I started viewing them as cognitive components inside a larger intelligence system.

That shift changed everything about how I designed my platform.

Introducing Gemma Mentor AI

Gemma Mentor AI is an adaptive AI tutoring system designed to feel less like a chatbot and more like an intelligent cinematic learning companion.

The goal was not simply to generate answers.

The goal was to create:

  • continuity
  • immersion
  • adaptive reasoning
  • lesson awareness
  • unified tutor identity
  • emotionally coherent learning flows

The learner should never feel:

  • model switching
  • reasoning fragmentation
  • context resets
  • instructional inconsistency

Instead, the experience should feel like interacting with a single evolving tutor.

Why Gemma 4 Became the Cognitive Core

What made Gemma 4 especially important for this architecture was not just capability.

It was architectural flexibility.

I needed a model that could function as:

  • a reasoning layer
  • an instructional intelligence layer
  • a semantic interpretation layer
  • an orchestration participant
  • a deployable adaptive component

Gemma 4 fit that role remarkably well.

The model enabled a system that could remain:

  • responsive
  • scalable
  • orchestration-friendly
  • educationally adaptable
  • suitable for cross-platform experiences

Rather than building around one giant monolithic intelligence pipeline, I designed the platform around specialized cognitive responsibilities.

Gemma 4 became the central intelligence layer responsible for:

  • educational reasoning
  • contextual lesson adaptation
  • semantic continuity
  • tutoring coherence

The Multi-Model Orchestration Layer

One of the most important engineering decisions in the project was introducing a dedicated orchestration layer.

Instead of routing every task through a single model, the system intelligently distributes responsibilities based on context and cognitive complexity.

The orchestration layer is responsible for:

  • routing between models dynamically
  • balancing speed vs reasoning depth
  • preserving conversational tone consistency
  • maintaining lesson continuity
  • preventing reasoning fragmentation
  • synchronizing semantic context

The learner never sees model switching.

They only experience a unified tutor identity.

That distinction matters enormously.

Because the future of AI UX is not about exposing model complexity.

It is about hiding complexity behind coherent experiences.

Semantic Intelligence Instead of Raw Text Generation

One of the biggest limitations of traditional AI tutoring systems is that they treat conversations primarily as text exchanges.

I wanted the platform to think semantically instead.

That led to the development of a semantic intelligence layer that interprets:

  • learner intent
  • confusion patterns
  • lesson progression
  • topic relationships
  • conceptual difficulty
  • pacing adaptation

Instead of merely generating replies, the system attempts to understand:

«What is the learner struggling with cognitively right now?»

That changes the interaction dramatically.

For example:

  • a beginner learner receives simplified conceptual scaffolding
  • an advanced learner receives abstraction and depth
  • confused learners receive guided decomposition
  • fast learners receive accelerated progression

The system adapts teaching strategy dynamically.

Building the Experience With Flutter

The presentation layer was built using Flutter.

One reason I chose Flutter was the ability to maintain a highly cinematic and fluid cross-platform experience while preserving architectural consistency across:

  • mobile
  • desktop
  • future web integrations

The UI philosophy was intentionally different from standard AI chat applications.

I wanted the platform to feel:

  • immersive
  • responsive
  • intelligent
  • cinematic
  • educationally alive

This meant designing interfaces that supported:

  • contextual transitions
  • adaptive tutoring flows
  • visual continuity
  • lesson immersion
  • conversational pacing

AI UX matters more than most people realize.

Even highly capable models can feel unintelligent if the interaction design breaks immersion.

Engineering Challenges Nobody Talks About

One thing I appreciated about the conversations around AI at Google I/O 2026 was the growing recognition that building AI systems is no longer just about model prompting.

The hardest problems are increasingly architectural.

Some of the most difficult engineering challenges in this project included:

Maintaining Tutor Identity Consistency

Different models reason differently.

Without orchestration safeguards, the tutor personality can become unstable.

The platform needed mechanisms for:

  • tone stabilization
  • semantic continuity
  • instructional consistency
  • conversational memory preservation

Balancing Latency vs Depth

Educational interactions are extremely sensitive to response timing.

Too slow:

  • immersion breaks

Too fast:

  • reasoning quality suffers

The orchestration layer had to dynamically balance:

  • response speed
  • reasoning complexity
  • educational depth
  • contextual relevance

Mobile Performance Constraints

Cross-platform AI systems face practical limitations:

  • memory constraints
  • rendering overhead
  • inference coordination
  • state synchronization

This forced careful optimization across the tutoring pipeline.

Context Preservation

Long educational conversations create enormous context management challenges.

A tutoring system cannot simply remember everything forever.

The platform needed semantic memory strategies that preserve:

  • conceptual progression
  • learner strengths
  • recurring confusion patterns
  • instructional continuity

Without overwhelming the active reasoning context.

The Bigger Realization

The biggest insight I took away from Google I/O 2026 was this:

«The future of AI applications will not belong to isolated single-model experiences.»

It will belong to orchestrated intelligence systems.

Systems built around:

  • adaptive routing
  • semantic memory
  • multimodal reasoning
  • deployable intelligence
  • unified UX
  • invisible complexity

That shift is profound.

Because users do not care which model answered a question.

They care whether the experience feels:

  • coherent
  • intelligent
  • adaptive
  • trustworthy
  • human-centered

That is the real design challenge of modern AI systems.

Why This Matters for Developers

One of the most exciting things about the broader Google AI ecosystem direction is that these ideas are becoming increasingly accessible to developers.

We are moving into an era where developers can build:

  • adaptive learning systems
  • orchestrated intelligence layers
  • multimodal educational platforms
  • deployable AI experiences
  • cinematic AI interfaces

Without requiring massive proprietary infrastructure.

That changes what small teams and independent developers can create.

And honestly, I think we are only beginning to see what becomes possible when orchestration, semantic intelligence, and deployable models converge.

Final Thoughts

The most important lesson I took from Google I/O 2026 was not that AI models are getting larger.

It’s that developers now have the tools to design AI experiences that feel unified, adaptive, and genuinely intelligent.

For me, Gemma 4 became more than just a model.

It became the cognitive core of an evolving tutoring architecture designed around continuity, orchestration, and immersive learning.

And I believe that is where the future of AI applications is heading next.

Not isolated chatbots.

But coherent intelligence systems.ng Challenge](https://dev.to/challenges/google-io-writing-2026-05-19)*

Looking for beta testers for a £60 server management application

LocalPanel is a desktop application that runs entirely locally, allowing you to easily manage any server accessible via SSH, install the necessary applications with a single click, review security settings, and ensure security by creating bash shells using drag-and-drop.

No data leaves your device; your passwords are encrypted using your device’s encryption, meaning that even if your device is hacked, your passwords remain securely stored.

I need users for beta testing. Please send an email or leave a comment stating your operating system: [email protected]

nesdzo.com/services





The Disk-Pressure Incident That Taught Me to Always Set LimitRanges and Other Lessons from Mirroring EKS Locally.

Vault Kubernetes auth flow — pod presents service account token, Vault validates, pod receives a Vault token.<br>

Part 6 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.

Previously in Part 5: We installed Istio with revision-based upgrades, MetalLB for LoadBalancer IPs, and practised traffic management with Gateways, VirtualServices, and fault injection. The cluster behaves. Now we wire up the last three pieces that turn it from “a working local cluster” into “a real mirror of our production EKS.”

The cluster works. Istio is running. MetalLB is handing out IPs. But it’s still missing three layers that make the production parity actually meaningful:

  • Vault Kubernetes auth — pods authenticate to Vault with their service account tokens, the same way they do in production. No hardcoded secrets, no static credentials.
  • Crossplane with the AWS provider — infrastructure compositions you can develop and test locally before they touch real AWS resources, or any other thought of OpenStack?
  • LimitRanges — default resource requests on every namespace. This one comes from a real incident I want to talk about.

The LimitRange story is the most important of the three, so I’ll tell it properly when we get there. First, the auth layer.

Vault Kubernetes auth.

Vault’s Kubernetes auth method lets pods authenticate by presenting their service account JWT. Vault validates the token against the Kubernetes API server and exchanges it for a Vault token with the appropriate policies attached.

On the production EKS clusters at work, this is how microservices retrieve database credentials, API keys, and TLS certificates: no hard-coded secrets, no secret sprawl, every issuance audit-logged in Vault.

Setting it up locally means I can test the full injection workflow without a VPN, and debug failures on a cluster where the stakes are zero.

Vault Kubernetes auth flow — pod presents service account token, Vault validates, pod receives a Vault token.<br>

Installing the Vault agent injector.

We deploy just the Vault agent injector in the lab cluster. It points to the external Vault VM rather than running its own Vault server:

# 💻 Mac
kubectx lab-cluster

helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

# Get the vault VM IP - does not persist across sessions
export VAULT_IP=$(orb run -m vault hostname -I | awk '{print $1}')
echo "VAULT_IP=$VAULT_IP"
helm install vault hashicorp/vault 
  --namespace vault --create-namespace 
  --set "injector.externalVaultAddr=http://$VAULT_IP:8200"

kubectl get pods -n vault
# vault-agent-injector-xxx   1/1   Running   0   30s

Configuring K8s auth on Vault.

Run this on the vault VM, pointing Vault at the lab cluster’s API server:

# 🖥️ VM: vault

# Re-export - always required, doesn't persist across sessions
export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_ROOT_TOKEN=$(grep 'Initial Root Token' ~/vault-init.txt | awk '{print $NF}')

# If Vault is sealed after a reboot:
# vault operator unseal $(grep 'Unseal Key 1' ~/vault-init.txt | awk '{print $NF}')
vault login $VAULT_ROOT_TOKEN

# Get CP_IP from the Mac terminal: orb run -m cp01 hostname -I | awk '{print $1}'
export CP_IP=<cp01-ip>

# Regenerate the CA cert if /tmp was cleared after reboot
vault read -field=certificate pki_k8s/issuer/default > /tmp/lab-ca.crt

# Enable Kubernetes auth (safe to re-run - ignores "already enabled")
vault auth enable -path=lab-k8s kubernetes 2>/dev/null || echo "already enabled"

# Configure - point Vault at the lab cluster API server
vault write auth/lab-k8s/config 
  kubernetes_host="https://$CP_IP:6443" 
  kubernetes_ca_cert=@/tmp/lab-ca.crt

vault read auth/lab-k8s/config

Testing K8s auth.

Create a simple role and test it from a pod:

# 🖥️ VM: vault

# Create a policy
vault policy write read-secrets - <<EOF
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
EOF
# Create a K8s auth role
vault write auth/lab-k8s/role/myapp 
  bound_service_account_names=myapp 
  bound_service_account_namespaces=default 
  policies=read-secrets 
  ttl=1h

# Write a test secret
vault secrets enable -path=secret kv-v2 2>/dev/null || true
vault kv put secret/myapp/config db_password="supersecret"
# 💻 Mac — deploy a pod with Vault annotations
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp
  namespace: default
---
apiVersion: v1
kind: Pod
metadata:
  name: vault-test
  namespace: default
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "myapp"
    vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp/config"
spec:
  serviceAccountName: myapp
  containers:
  - name: app
    image: busybox
    command: ["sleep", "3600"]
EOF

# Check the secret was injected
kubectl exec vault-test -c app -- cat /vault/secrets/config
# db_password: supersecret

If that last line returns the password, the whole chain works: service account JWT → Vault validation → Vault token → secret retrieval → file injection. Every link of the chain is what a real production app does.

Crossplane.

Crossplane turns a Kubernetes cluster into a universal control plane for cloud infrastructure. Instead of Terraform modules or CloudFormation stacks, you define infrastructure as Kubernetes custom resources, and Crossplane reconciles them continuously.
I use it at work to provision AWS resources (EKS node groups, RDS, S3 buckets, IAM roles) and VMware Cloud Director resources through a custom provider. The lab version mirrors the AWS side of that.
Crossplane composites — an XR triggers a Composition that creates multiple Managed Resources across cloud providers.

Installation:

# 💻 Mac
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

# Composition Functions are enabled by default in recent versions.
# The --enable-composition-functions flag was removed.
helm install crossplane crossplane-stable/crossplane 
  --namespace crossplane-system --create-namespace

kubectl get pods -n crossplane-system -w
# NAME                                       READY   STATUS    AGE
# crossplane-xxx                             1/1     Running   60s
# crossplane-rbac-manager-xxx               1/1     Running   60s

Installing the AWS provider

💻 Mac

kubectl apply -f – <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-ec2
spec:
package: xpkg.upbound.io/upbound/provider-aws-ec2:latest
EOF

kubectl get pkg

NAME INSTALLED HEALTHY PACKAGE AGE

provider-aws-ec2 True True xpkg.upbound.io/upbound/provider-… 60s

A minimal composition:

A bare-minimum ProviderConfig enough to verify the install is working:

# 💻 Mac
kubectl apply -f - <<EOF
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
EOF

In a real setup, you create a IRSA ( IAM Role for Service Account) to authenticate and give the provider permission to create and monitor resources. For local validation, the provider installs, and the compositions can be validated structurally without ever calling AWS.

The LimitRange story.

This is the one that came from a real incident at work.

We had repeated disk-pressure events in our production EKS cluster. Pods with no resource requests had crept into a few namespaces — someone deployed a YAML that omitted resources: entirely, and nobody caught it in review. The Kubernetes scheduler had no signal about their consumption, so nodes ended up overcommitted. Then ephemeral storage filled up, eviction kicked in, and a couple of unrelated pods went down with it. Total downtime measured in tens of minutes. Cause-and-effect chain that took a while to untangle.

The fix is one of the most boring features in Kubernetes: LimitRanges. They set default resource requests and limits at the namespace level. Any container that doesn’t specify its own requests gets the defaults applied automatically by the admission controller. The scheduler always has a signal. Overcommit becomes a deliberate choice, not an accident.

# 💻 Mac
kubectl apply -f - <<EOF
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: default
spec:
  limits:
  - default:
      memory: 512Mi
      cpu: 500m
    defaultRequest:
      memory: 128Mi
      cpu: 100m
    max:
      ephemeral-storage: 2Gi
    type: Container
EOF

Apply this to every namespace that hosts workloads. In production, I now apply it as a post-provisioning step on every new namespace:

# 💻 Mac — apply to multiple namespaces
for ns in default vault crossplane-system istio-system; do
  kubectl apply -f limitrange.yaml -n $ns
done

The ephemeral-storage max is the part that specifically addresses the disk-pressure failure mode — it bounds how much scratch space a container can consume, which is what spirals when ephemeral storage runs unbounded.

Verifying the complete EKS mirror.

Let’s confirm the whole stack is up:

# 💻 Mac
kubectl get nodes -o wide
# NAME       STATUS   ROLES           VERSION
# cp01       Ready    control-plane   v1.34.x
# worker01   Ready    <none>          v1.34.x
# worker02   Ready    <none>          v1.34.x

kubectl get pods -A
# Cilium/Calico, CoreDNS, istiod-1-26, MetalLB, Crossplane, Vault injector - all Running

Full EKS mirror stack running — all components healthy.<br>

Local vs production — what’s the same and what differs:

Local vs production — what’s the same and what differs:
The only meaningful differences are the CNI (because of OrbStack’s VM capabilities, as we covered in Part 4) and the LoadBalancer implementation. Everything else is identical in configuration. The mental model from this lab transfers directly to the production cluster, and vice versa.

In the final article: How to stop and start the lab without losing state, the CKS exam scenarios this cluster was purpose-built for, and the shell aliases that make the whole thing pleasant to live with.

← Part 5: How I Practise Istio Upgrades Locally Before Touching Production EKS | Part 7: The Day 2 Reality of Running a Kubernetes Lab on Your Mac: Stop/Start, CKS Scenarios, and What I Learned Building It →

I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications , currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.
originally published at blog.arkilasystems.com