How to get data into and out of steps — constructor injection, input chaining, context keys, metadata publishing, and mixed patterns
The first question everyone asks: “How do I get data into my steps?”There are three levels, presented as a progression. Most users start at Level 1 and add complexity only when needed.
Configuration known at build time — model, prompt template, threshold, API client. Same step class, different parameters, different behavior.
static class TranslateStep implements Step<Object, Object> { private final ChatClient chatClient; private final String targetLanguage; TranslateStep(ChatClient chatClient, String targetLanguage) { this.chatClient = chatClient; this.targetLanguage = targetLanguage; } @Override public Object execute(AgentContext ctx, Object input) { return chatClient.prompt() .user("Translate this to " + targetLanguage + ": " + input) .call().content(); }}// Same class, different config → different behaviorStep<Object, Object> toFrench = new TranslateStep(chat, "French");Step<Object, Object> toSpanish = new TranslateStep(chat, "Spanish");Workflow.<String, Object>define("translate") .step(write) .then(toFrench) // or toSpanish — swap at build time .run("the sunrise over the mountains");
Use when: Configuration is static — model selection, prompt templates, thresholds, API clients. Works exactly like a Spring @Bean with constructor injection.
What if step C needs step A’s output, but step B sits between them? The executor auto-propagates every step’s output into AgentContext under Steps.outputOf(stepName). Any downstream step can read any prior step’s result by name.
Workflow.<String, Object>define("context-keys") .step(Step.named("generate-story", (ctx, in) -> chat.prompt().user("Write a story about: " + in).call().content())) .then(Step.named("score-story", (ctx, in) -> chat.prompt().user("Rate this story 1-10: " + in).call().content())) .then(Step.named("summarize", (ctx, in) -> { // `in` is the score (from score-story via input chaining) // Read the story from generate-story via context Object story = ctx.get(Steps.outputOf("generate-story")).orElse("unknown"); return "Story: " + story + "\nScore: " + in; })) .run("a time-traveling cat");
A step’s primary job is to return a value — the category string for branching, the translated text for the next step. But sometimes a step also knows metadata that other steps need: the confidence score, the detected language, the list of sources used.Without updateContext(), the only way to pass all of this is to return a record — which forces every downstream step to know about that record type, killing reusability. With updateContext(), the step returns its primary output AND publishes metadata as typed context keys.
Example: Classification with confidence and reasoning
static class ClassifierStep implements Step<Object, Object> { // Published constants — the step's "output contract" static final ContextKey<Double> CONFIDENCE = ContextKey.of("classifier.confidence", Double.class); static final ContextKey<String> REASONING = ContextKey.of("classifier.reasoning", String.class); private final ChatClient chat; private double confidence; private String reasoning; ClassifierStep(ChatClient chat) { this.chat = chat; } @Override public String name() { return "classifier"; } @Override public Object execute(AgentContext ctx, Object input) { String response = chat.prompt() .user("Classify as 'medical' or 'legal'. " + "Reply: CATEGORY: <cat>\nCONFIDENCE: <0-1>\nREASONING: <why>\n\n" + "Request: " + input) .call().content(); // Parse structured response String category = "unknown"; for (String line : response.split("\n")) { if (line.startsWith("CATEGORY:")) category = line.substring(9).strip().toLowerCase(); else if (line.startsWith("CONFIDENCE:")) { try { confidence = Double.parseDouble(line.substring(11).strip()); } catch (NumberFormatException e) { /* keep default */ } } else if (line.startsWith("REASONING:")) reasoning = line.substring(10).strip(); } return category; // primary output — for branch routing } @Override public AgentContext updateContext(AgentContext ctx, Object output) { return ctx.mutate() .with(CONFIDENCE, confidence) .with(REASONING, reasoning) .build(); }}
The classifier returns "medical" as its primary output (used by the branch). The confidence and reasoning are published as side-channel metadata. Any downstream step reads them without knowing anything about the classifier’s internals:
static class DetectAndTranslateStep implements Step<Object, Object> { static final ContextKey<String> DETECTED_LANGUAGE = ContextKey.of("translate.detectedLanguage", String.class); private final ChatClient chat; private String detectedLang; DetectAndTranslateStep(ChatClient chat) { this.chat = chat; } @Override public String name() { return "detect-and-translate"; } @Override public Object execute(AgentContext ctx, Object input) { // ... call LLM, parse "LANGUAGE:" and "TRANSLATION:" from response ... return translation; // primary output — the English text } @Override public AgentContext updateContext(AgentContext ctx, Object output) { return ctx.mutate().with(DETECTED_LANGUAGE, detectedLang).build(); }}// Downstream step reads the language without coupling to the translatorStep<Object, Object> audit = Step.named("audit", (ctx, in) -> { String lang = ctx.get(DetectAndTranslateStep.DETECTED_LANGUAGE).orElse("unknown"); return "Translated from " + lang + ": " + in;});
Use when: A step produces a primary result AND secondary data (confidence, language, token counts, source lists). The step class owns ContextKey constants as its published output contract. Downstream steps read by key — no coupling to the producing step’s record type.
Most users never need updateContext(). Start with input chaining (Pattern 1). Add Steps.outputOf() when you need non-adjacent data (Pattern 3). Reach for updateContext() only when a step genuinely produces metadata that should travel separately from its primary output.