Skip to main content

SDK Automation Pipeline Guide

How the com.n3plugins.sdk.* automation surface fits together, which tool to reach for per task type, and how to intercouple them. Written as a reference for both suite contributors and plugin authors consuming the SDK.

Every claim and code shape here is drawn from the live source as of 2026-06-07. The worked examples are real game flows - bank kit withdrawal, fletching, jewelry enchanting - each cross-linked to its dedicated reference page.


1. The layered model

┌────────────────────────────────────────────────────────────┐
│ TASK DRIVER LAYER │
│ AutomationStateMachine │ TaskPipeline │
│ (branching / looping) │ (linear ordered sequences) │
├────────────────────────────────────────────────────────────┤
│ DOMAIN BUILDER LAYER │
│ CombatWorkflowBuilder ProductionWorkflowBuilder │
│ BankWorkflowBuilder EnchantingWorkflowBuilder │
│ AbstractWorkflowBuilder (base; compiles to StepHandler) │
├────────────────────────────────────────────────────────────┤
│ LOADOUT LAYER │
│ InventoryLoadout EquipmentLoadout LoadoutItem │
│ (declare what to carry / wear for bank reconcile cycles) │
├────────────────────────────────────────────────────────────┤
│ PRECONDITION LAYER │
│ InventoryPlan - assert inventory before a step │
│ Equipment.search() - assert equipment before a step │
│ Cooldowns / TickDelay - timing gates │
├────────────────────────────────────────────────────────────┤
│ PACING LAYER │
│ ActionPacer (suite-wide; advanced by PacketUtilsPlugin) │
├────────────────────────────────────────────────────────────┤
│ INTERACTION LAYER │
│ AutomationApi (16 sub-APIs) + InteractionApi.actions.* │
│ every action returns InteractionResult │
└────────────────────────────────────────────────────────────┘

A plugin almost never touches all layers at once. The normal shape is: a task driver sequences interaction-layer calls, optionally guarded by the precondition layer, with loadouts feeding the bank/restock builders. The pacing layer is invisible to the driver - see §3.


2. Where pacing lives (read this before using a pipeline)

ActionPacer is static and suite-wide. PacketUtilsPlugin.onGameTick advances it once per tick via ActionPacer.onTick(System.currentTimeMillis()) (see suite-wiring.md). Plugins do not advance it.

The gate is consumed inside the interaction layer, not by your driver. The canonical shape, found throughout InteractionApi.actions.*, is:

long nowMs = System.currentTimeMillis();
if (!ActionPacer.isReady(nowMs)) {
return InteractionResult.fail(InteractionStatus.PACED, "paced");
}
// ... dispatch the packet ...
ActionPacer.recordAction();
return InteractionResult.success(...);

So when a paced action is blocked, it returns an InteractionResult whose status is InteractionStatus.PACED. Your driver never calls isReady/recordAction itself - it only sees the InteractionResult and reacts to it.

The InteractionResult → StepResult bridge has one sharp edge

StepResult.fromInteraction(result) maps only SUCCESS → success and everything else → failed (verified in StepResult.java):

public static StepResult fromInteraction(InteractionResult result) {
if (result == null) return failed("Interaction result is null");
if (result.succeeded()) return success(result.getMessage());
return failed(result.getMessage());
}

InteractionResult.succeeded() is status == SUCCESS - nothing else. So PACED, TARGET_NOT_FOUND, WIDGET_HIDDEN, and a real failure all collapse to StepStatus.FAILED. The bridge cannot tell "paced, try again next tick" from "broken."

That is survivable because of how the pipeline treats FAILED - see next.

TaskPipeline.tick() never self-aborts

From TaskPipeline.java, tick() only branches on three statuses:

StepResult statusTaskPipeline.tick() effect
SUCCESSindex++ - advance to next step
WAITarm an internal TickDelay(delayTicks); re-run this step after it expires
RESETindex = 0
RETRY(no branch) - index unchanged, re-run same step next tick
FAILED(no branch) - index unchanged, re-run same step next tick

So FAILED and RETRY are identical to the pipeline: stay on the current step and re-run it next tick. A PACED action therefore retries automatically until the gate clears, then succeeds and advances - exactly what we want, achieved via FAILED, not via any pacing awareness in the pipeline.

The cost of the sharp edge: because a genuine failure also just re-runs forever, the pipeline will never give up on its own. The driver that owns the pipeline must watch pipeline.lastResult(), pipeline.currentStepTicks(), and/or pipeline.currentStepAttempts() and halt() itself. This is exactly what TutorialIslandController already does with its ticksOnCurrentVarp > 60 and nameWidgetAbsentTicks > 20 guards.

If you need a step to distinguish "paced/transient" from "abort," use the result-aware bridge that matches the step's intent:

  • StepResult.fromInteractionPaced(result) retries only PACED.
  • StepResult.fromInteractionTransient(result) retries the SDK default transient set: PACED, target/widget temporarily missing or hidden, and bank/deposit-box/production interfaces not open yet.
  • StepResult.fromInteractionRetrying(result, statuses...) lets the caller choose the exact statuses that should become RETRY.

Functionally retry and failed behave the same inside TaskPipeline, but the distinction is what your driver's halt logic reads off lastResult().


3. Choosing a task driver

The work is…UseWhy
A fixed-order multi-tick sub-sequence (click → type → confirm)TaskPipelineIts index is the sub-step state; no manual counters
An observation loop re-evaluated every tick (talk if dialogue up; walk if not; gather until count)Plain per-tick handler - no driverState lives in the game, not in your code. A pipeline would fight the re-evaluation
Branching with recovery / terminal error+success statesAutomationStateMachineGuards route between named states; built-in complete()/error()
A known domain: combat / production / banking / enchantingThe matching domain builderEncodes the domain's happy-path; you supply a Services impl

The single most common mistake is reaching for a driver when the work is an observation loop. OSRS dialogue and gathering are stateless - see dialogue-helper.md and the suite rule "if the target widget is visible this tick, click it; otherwise do nothing." Forcing those into a TaskPipeline adds a state machine on top of a system that is already a state machine (the game varp), and the two desync. Keep them as per-tick handlers.


4. Task type: linear multi-tick sub-sequence → TaskPipeline

Shape: a handful of actions that must happen in order, one per tick, where re-evaluating from scratch each tick would re-fire earlier actions. The pipeline's internal index is the step state - you never own a counter field.

TaskPipeline is directly instantiable - no subclassing:

TaskPipeline.create()
.step("name", () -> StepResult.fromInteraction(SomeActions.doThing()))
.step("...", () -> StepResult.waitTicks(1, "..."))
// driver calls pipeline.tick() once per game tick

TaskStep is StepResult execute(). StepResult factories: success(), success(msg), waitTicks(n), waitTicks(n, msg), retry(msg), failed(msg), reset(msg), fromInteraction(InteractionResult).

Flow 1 - withdraw a bank kit, then equip it

A fixed linear sequence: open the bank, withdraw runes and food, close, then equip a weapon. Each game-call returns an InteractionResult, bridged with StepResult.fromInteraction; the wait-bank step uses waitTicks to let the interface settle.

TaskPipeline withdrawKit() {
return TaskPipeline.create()
.step("open-bank", () -> StepResult.fromInteraction(BankActions.openNearest()))
.step("wait-bank", () -> BankActions.isOpen()
? StepResult.success()
: StepResult.waitTicks(1, "Opening bank"))
.step("withdraw-food", () -> StepResult.fromInteraction(
BankActions.withdraw(ItemID.LOBSTER, 6)))
.step("withdraw-runes",() -> StepResult.fromInteraction(
BankActions.withdraw(ItemID.LAW_RUNE, 20)))
.step("close-bank", () -> StepResult.fromInteraction(BankActions.close()))
.step("equip-weapon", () -> StepResult.fromInteraction(
EquipmentActions.equip("Abyssal whip")));
}

Driver:

private TaskPipeline kit;

@Subscribe
public void onGameTick(GameTick event) {
if (kit == null) kit = withdrawKit();
if (kit.complete()) { kit = null; return; } // done; ready for next run

StepResult r = kit.tick();
status = "Kit: " + kit.currentStepName();
if (r.getStatus() == StepStatus.FAILED && stalledTooLong()) {
// The pipeline never aborts itself (see §2) - the driver decides to give up.
kit = null;
}
}

Why no counter field: the pipeline advances its own index on each SUCCESS and re-runs the same step on WAIT/RETRY/FAILED. Rebuilding the pipeline (kit = withdrawKit()) resets the sequence - no manual step bookkeeping. See task-pipeline.md for the full status table.

Terminal steps are often observation loops, not fire-once actions. If your last step is "attack until in combat" or "wait until the interface closes," it keeps returning a non-SUCCESS result and re-runs - it may never reach complete(). That is fine: the pipeline owns the fixed-order prefix, and the tail merges into the §5 observation pattern. Let the outer condition (a varp, a count) end it, not the pipeline.


5. Task type: observation loop → plain per-tick handler (no driver)

Shape: every tick, look at the game, do at most one thing. The "progress" is a live inventory count or game var, not a counter you own. This is most of Dialogue Helper and every gather/skill loop.

These need no SDK driver - a plain onGameTick body is correct:

// Gather loop: gate on a live inventory count, not a step index.
// idle() is your own "not currently animating/moving" check.
@Subscribe
public void onGameTick(GameTick event) {
int logs = Inventory.search().withName("Yew logs").count();
if (logs < target && idle()) {
AutomationApi.objects.interact("Yew tree", "Chop down");
}
}
// Dialogue loop: continue if a dialogue is up, else talk. Re-derived each tick.
@Subscribe
public void onGameTick(GameTick event) {
if (AutomationApi.dialogue.isPresent()) {
AutomationApi.dialogue.continueDialogue();
} else if (npcInRange("Banker")) {
AutomationApi.npcs.interact("Banker", "Talk-to");
}
}

Why no pipeline: the player can be interrupted, the dialogue can re-open, the animation can drop - the game owns the state and the handler reacts. A pipeline index here would desync from the live state the moment anything unexpected happens. This is the suite's "per-tick self-limiting" pattern; the stateless rule is spelled out in dialogue-helper.md.

Pacing still applies - AutomationApi.objects.interact(...) is gated by ActionPacer at the interaction layer and no-ops on a blocked tick. The handler doesn't care; it tries again next tick. That is why observation loops need no pacing plumbing at all.


6. Task type: branching with recovery → AutomationStateMachine

Shape: named states, conditional transitions, real terminal success/error. Built fluently; driven by pulse() once per tick.

AutomationStateMachineBuilder API:

AutomationStateMachine m = AutomationStateMachineBuilder.create()
.state("validate")
.status("Validating")
.transition("error", ctx -> magicLevel() < required)
.transition("openBank",ctx -> !hasRunes())
.transition("cast", ctx -> true)
.state("openBank")
.status("Opening bank")
.execute(ctx -> openNearestBank())
.transition("error", ctx -> Boolean.TRUE.equals(ctx.get("bankFailed")))
.transition("cast", ctx -> isBankOpen())
.state("cast")
.status("Casting")
.execute(ctx -> castOnce())
.transition("validate", ctx -> true) // loop
.state("error").error()
.state("complete").complete()
.build("validate");

while (m.pulse()) { Delays.tick(); } // or call m.pulse() once per onGameTick

pulse() returns false at a terminal state. AutomationContext carries shared state plus tick-based deadlines (startCooldown(key, ticks), isCooldownActive(key), hasTimedOut(key, ticks)) so states can pace themselves without owning counters.

Real consumer (Flow 3): EnchantingWorkflowBuilder.create(EnchantingPlan) returns one of these machines (validate → open bank → prepare → cast loop, with ERROR_MAGIC/ERROR_RUNES/ERROR_BANK terminals). Use it as the reference for any "do X repeatedly, restock when out, abort on a hard stop" flow. Full state graph and EnchantingPlan fields: automation-state-machine.md.

Transitions are evaluated in declaration order; first match wins. Put the error/abort guards before the happy-path guard.


7. Task type: domain builders

Each domain builder compiles a happy-path StepHandler (or a state machine for enchanting) from a Services interface you implement. The builder owns the step ordering; your Services owns the actual game calls and is the unit-test seam.

7a. Combat → CombatWorkflowBuilder

StepHandler h = new CombatWorkflowBuilder(services)
.findTarget(npc -> "Chicken".equals(npc.getName()))
.attackTarget()
.waitForTargetDeath()
.eatFoodBelow(50, FOOD_ID)
.lootItems(FEATHER_ID)
.build();
while (h.step()) { Delays.tick(); }

Services you must implement: getCurrentHealthPercent, getSpecEnergy, hasFood/eatFood, hasPotion/drinkPotion, toggleSpec, enablePrayers/disablePrayers, findTarget(Predicate<NPC>), attack(NPC), isDead(NPC), loot(int...).

Builder verbs: findTarget, attackTarget, waitForTargetDeath, eatFoodBelow, drinkPotionIf(BooleanSupplier, int...), toggleSpecAt, enablePrayersDuring, disablePrayers, lootItems.

Don't over-apply it. A trivial "attack nearest NPC, then idle while in combat" loop with no food, prayer, spec, or loot is a §5 observation loop - wiring a full Services for it is more code than a two-line handler. Reach for the builder once the eat/loot/target-death cycle is real. Full reference: combat-and-prayer.md.

7b. Production → ProductionWorkflowBuilder

For interfaces that open a production widget (smelting, fletching, crafting):

StepHandler h = new ProductionWorkflowBuilder(services)
.waitForProductionOpen()
.selectOption("Bronze dagger") // or selectOption(index)
.selectQuantity(ProductionQuantity.ALL)
.waitForProductionComplete()
.bankAndRestock(loadout)
.build();

Services: isProductionOpen, selectOption(String|int), selectQuantity, enterCustomQuantity, isInventoryFull, isProductionStopped, bankAndRestock(InventoryLoadout). Full reference: production-workflow.md.

Use-item-on-item is NOT this. A direct combine (item-on-item or item-on-object) that never opens a make-X interface - so isProductionOpen() would never be true - is a §5 observation loop, not a production flow. The production builder is for the make-X menu (fletching, smelting, crafting), where a quantity dialog drives the run.

7c. Banking → BankWorkflowBuilder (+ loadouts)

StepHandler h = BankWorkflowBuilder.create()
.reconcile(inventoryLoadout, equipmentLoadout) // open → deposit → withdraw → equip
.build();

Granular verbs also exist: openNearest, depositInventory, depositEquipment, withdraw(InventoryLoadout), equip(EquipmentLoadout). create() is a static factory; unlike the combat/production builders the bank Services is wired internally (swappable in tests via setServicesForTesting). Full reference: banking.md.

A hint-arrow-driven booth or any single fixed interaction is not a bank cycle

  • those stay §5 observation loops. BankWorkflowBuilder is for real deposit/withdraw/equip cycles against a loadout (e.g. Market Alcher restock, Tree Woodcutter supply recovery).

8. Loadouts (the declare-vs-assert distinction)

TypeRoleFed to
InventoryPlanAssert - "does inventory satisfy this right now?"guard before a step
InventoryLoadoutDeclare - "inventory should contain this after a bank run"BankWorkflowBuilder.withdraw/reconcile, ProductionWorkflowBuilder.bankAndRestock
EquipmentLoadoutDeclare - "this should be worn after restock"BankWorkflowBuilder.equip/reconcile

Build a loadout from LoadoutItem (immutable builder):

InventoryLoadout inv = new InventoryLoadout();
inv.add(LoadoutItem.builder(NATURE_RUNE_ID).amount(100).build());
inv.add(LoadoutItem.builder(FIRE_RUNE_ID).amount(400).stackable(true).build());

EquipmentLoadout eq = new EquipmentLoadout();
eq.add(LoadoutItem.builder(STAFF_OF_FIRE_ID)
.slot(EquipmentInventorySlot.WEAPON).build());

Loadout query surface: isFulfilled(), getRequiredItems(), getMissingItems() / getUnequippedItems() (equipment), getExcessItems(), getForeignItemIds(), plus an ItemDepletionListener hook. Full reference: loadouts.md.

Precondition guard with InventoryPlan

InventoryPlan answers a yes/no before you commit to a step:

InventoryPlan plan = InventoryPlan.create() // live snapshot
.require(NATURE_RUNE_ID, 1)
.require(ALCH_TARGET_ID, 1)
.requireFreeSlots(1);

if (!plan.satisfied()) {
// describeMissing() → human-readable; branch to restock
}

InventoryPlan.create() reads a live snapshot; create(InventorySnapshot) takes a fake for tests. Equipment has no EquipmentPlan - assert it with the query layer instead:

boolean staffOn = Equipment.search().withName("Staff of fire").exists();

(EquipmentItemQuery: withId, withName, nameContains*, withAction, exists, count, first, single, result, …)


9. Choosing wrong: the anti-patterns

  • Don't wrap an observation loop in a driver. Gather/dialogue/skill loops are re-evaluated every tick by design; a TaskPipeline index desyncs from the game's own state (§5).
  • Don't reach for a domain builder when a two-line handler suffices. A trivial attack/interact with no eat/loot/restock cycle does not need Services wiring (§7a).
  • Don't route use-item-on-item through ProductionWorkflowBuilder. The builder handles the make-X menu, not direct combines (§7b).
  • Don't treat a single fixed interaction as a bank cycle. BankWorkflowBuilder earns its keep only across deposit/withdraw/equip against a loadout (§7c).
  • Don't call ActionPacer from your driver. The interaction layer consumes it; InteractionStatus.PACED is the only contact you have (§2).

10. Quick reference

You have…Reach forDrives via
Fixed-order multi-tick sub-stepsTaskPipelinetick() per game tick
Observation loop (game owns state)plain per-tick handleryour onGameTick
Branching + recovery + terminal statesAutomationStateMachinepulse()
Combat with eat/loot/target cycleCombatWorkflowBuilderStepHandler.step()
Make-X production interfaceProductionWorkflowBuilderStepHandler.step()
Real bank restock cycleBankWorkflowBuilder + loadoutsStepHandler.step()
Repeated enchant/cast with restockEnchantingWorkflowBuilderAutomationStateMachine.pulse()
"Do I have the items right now?"InventoryPlansatisfied()
"Is this equipped right now?"Equipment.search()exists()
Per-key tick cooldownCooldownsready(key) / delay(key,n) / tick()
Single tick delayTickDelayready() / delay(n) / tick()

Pacing is never your concern at the driver level: it lives in the interaction layer, surfaces as InteractionStatus.PACED, and (via FAILED) causes pipelines to re-run the current step until the gate clears.