---
title: "Where Does a Flue Agent Fail? Separating Skill Execution and Structured Output"
description: "A Flue 1.0 Beta verification note that compares normal prompting with explicit skill execution on the same issue input, separating finish completion, schema validation, and classification quality."
lang: "en"
canonical: "https://llm-lab.dev/en/posts/flue-skill-structured-output-comparison/"
source: "https://llm-lab.dev/en/posts/flue-skill-structured-output-comparison.md"
publishedAt: "2026-07-03"
updatedAt: "2026-07-03"
category: "Flue"
tags:
  - "flue"
  - "agent"
  - "skill"
  - "structured-output"
  - "eval"
---

# Where Does a Flue Agent Fail? Separating Skill Execution and Structured Output

import LinkCard from "../../components/LinkCard.astro";

> [!NOTE]
> What this article confirms: For the same issue payload, both `session.prompt(..., { result })` and `session.skill('triage', { args, result })` reached Flue's `finish` condition and returned Valibot-valid structured output. The key takeaway is that schema-valid JSON does not prove classification quality, and the earlier `Agent did not call finish` failure belongs to the tool-protocol layer before schema validation even starts.

When I built an issue triage agent with Flue 1.0 Beta, the most memorable failure was that the model seemed to understand `finish` in natural language, but could not reach the `finish` tool call that Flue expected.

<LinkCard
  href="https://llm-lab.dev/posts/flue-1-0-beta-issue-triage-agent/"
  title="Trying Out a GitHub Issue Triage Agent with Flue 1.0 Beta"
  description="An experiment log where I used Flue 1.0 Beta's agent, skill, and workflow features to build a triage agent that returns structured severity, reproducibility, and label candidates from GitHub issues."
  siteName="つれづれなる Agent OPS"
  image="/images/posts/flue-1-0-beta-issue-triage-agent/heroImage.webp"
/>

After that, I sent Flue's `observe(...)` events to Langfuse and checked both success and failure cases as traces.

<LinkCard
  href="https://llm-lab.dev/posts/flue-langfuse-observability-issue-triage/"
  title="Streaming Flue Observe Events to Langfuse: Monitoring an Issue Triage Agent"
  description="An experiment log where I redact Flue 1.0 Beta observe events before sending them to Langfuse, tracking the issue triage workflow's runId, model, and results."
  siteName="つれづれなる Agent OPS"
  image="/images/posts/flue-langfuse-observability-issue-triage/heroImage.webp"
/>

One question still bothered me.
In the issue triage agent, the workflow called `session.prompt(..., { result })`. If I explicitly call the Flue skill with `session.skill('triage', { result })`, can I still receive structured output in the same way?

According to Flue's documentation, `prompt()`, `skill()`, and `task()` can all receive a Valibot schema through `options.result`. So this is a meaningful API-level comparison. This time, I passed the same issue payload through two routes, `session.prompt()` and `session.skill()`, and checked what stays the same and where the evaluation criteria start to diverge.

## The Short Version

With this input, both `session.prompt(..., { result })` and `session.skill('triage', { args, result })` succeeded, and both returned structured data that matched the Valibot schema.

However, the outputs were not identical. `severity` and `reproducible` matched, but the granularity of `labels` and `summary` differed slightly. I do not read this as a failure. It means that even inside the same schema, model judgment and invocation route can still introduce variation.

![Comparison result showing both session.prompt and session.skill returning structured output successfully](/images/posts/flue-skill-structured-output-comparison/001-skill-prompt-compare-result.webp)

The takeaway is not that `session.skill()` has a problem with structured output. On the contrary, `session.skill()` can also return structured data. The important point is that agent frameworks need to be evaluated in layers: API connectivity, tool protocol, schema validation, and output quality. The earlier `Agent did not call finish` failure belongs before schema validation, at the tool protocol layer.

## Building a Comparison Workflow

For this verification, I reused the existing issue triage agent and the `triage` skill, and only added a comparison workflow.

```ts
export async function run({ init, payload }: FlueContext<IssuePayload>) {
	const harness = await init(issueTriage);
	const promptSession = await harness.session('prompt-result');
	const skillSession = await harness.session('skill-result');

	const promptResult = await captureInvocation('prompt-result', async () => {
		const response = await promptSession.prompt(`Use the triage skill to classify this GitHub issue.

Title:
${payload.title}

Body:
${payload.body}

Return only the structured triage result.`, {
			result: TriageResult,
		});

		return response.data;
	});

	const skillResult = await captureInvocation('skill-result', async () => {
		const response = await skillSession.skill('triage', {
			args: {
				title: payload.title,
				body: payload.body,
			},
			result: TriageResult,
		});

		return response.data;
	});

	return {
		input: {
			title: payload.title,
			bodyCharacters: payload.body.length,
		},
		results: [promptResult, skillResult],
	};
}
```

The key point is that I separated `prompt-result` and `skill-result` into different sessions. If I ran both operations in the same session, the conversation history from the first run could affect the second run. What I wanted to compare here was the difference between a `prompt()` route that instructs the agent to use the skill and a `skill()` route that explicitly invokes the skill, while keeping the same agent, schema, and payload. Separating the session names keeps the comparison narrower.

The schema is the same one used in the existing issue triage workflow.

```ts
const TriageResult = v.object({
	severity: v.picklist(['low', 'medium', 'high', 'critical']),
	reproducible: v.boolean(),
	labels: v.array(v.string()),
	summary: v.string(),
});
```

The `args` passed to `session.skill()` are treated as the skill input. I also added one sentence to the `triage` skill so that it knows to use `args.title` and `args.body` when they are provided.

```markdown
Read the issue title and body provided in the prompt. If this skill is invoked
with `args.title` and `args.body`, use those values as the issue title and body.
```

Without this, the model may have to infer where the issue body is from the Markdown skill alone. If a skill is meant to be a reusable unit of work, the expected arguments in the skill text should line up with the input contract in code.

## What the Verification Command Means

For execution, I used an npm script added for this article. It is not an official Flue command; it is a thin wrapper that internally calls `flue run compare-skill-prompt --target node --payload ...`.

```sh
npm run compare:skill-prompt -- '{"title":"Dashboard is blank after login","body":"Steps: log in, open /dashboard. Expected widgets. Actual blank white screen in Chrome 126."}'
```

The input is the issue `title` and `body`. The target is the comparison workflow. I checked three things:

- Whether `session.prompt(..., { result })` returns schema-valid data as a prompt route instructed to use the triage skill.
- Whether `session.skill('triage', { args, result })` returns schema-valid data as an explicit triage skill invocation route.
- Whether both routes reach Flue's completion condition, `finish`.

Both routes returned `success`.

```json
{
  "input": {
    "title": "Dashboard is blank after login",
    "bodyCharacters": 90
  },
  "results": [
    {
      "route": "prompt-result",
      "status": "success",
      "data": {
        "severity": "high",
        "reproducible": true,
        "labels": ["bug", "frontend", "dashboard"],
        "summary": "ダッシュボードにログイン後、ウィジェットが表示されるはずの画面が真っ白になるという問題。Chrome 126で再現可能とのこと。再現手順は「ログイン後 /dashboard を開く」と明記されており、事象は明確。ただし、コンソールエラーやサーバーログ、他ブラウザでの確認結果などの詳細情報は不足している。"
      }
    },
    {
      "route": "skill-result",
      "status": "success",
      "data": {
        "severity": "high",
        "reproducible": true,
        "labels": ["bug", "dashboard", "ui"],
        "summary": "ログイン後にダッシュボード（/dashboard）へアクセスすると、ウィジェットが表示されるべきところが空白の白い画面になってしまう。Chrome 126で発生。"
      }
    }
  ]
}
```

What is interesting here is that the results are not identical. They are different within the same schema. `prompt-result` includes `frontend` and mentions missing extra information in the summary. `skill-result` includes `ui` and gives a shorter summary. Both are valid structured data.

In other words, structured output fixes the shape of the output, but it does not fully fix the judgment inside that shape. If I conflate those two, I may wrongly conclude that "it returned JSON, so evaluation is done." Whether the output is shaped for downstream processing and whether the classification is good enough are separate evaluation axes.

## Which Layer Did the Earlier finish Failure Belong To?

In the earlier failure log, the model mentioned `finish`. However, it could not return the tool call required by Flue's execution protocol, and eventually failed with this error:

```txt
The agent gave up: Agent did not call `finish` or `give_up` after 33 attempts.
```

Calling this simply a "structured output failure" is too coarse. With this comparison in mind, the layers look like this:

![Diagram separating Agent did not call finish into API connectivity, protocol, and schema layers](/images/posts/flue-skill-structured-output-comparison/002-finish-failure-layer.webp)

At the API connectivity layer, the provider accepted the request and returned model output. Natural language output existed. But the model did not reach the `finish` tool call expected by Flue. In that state, it is not yet meaningful to evaluate whether the result matches a Valibot schema. Schema validation only matters after the agent satisfies the completion protocol.

> Wait, it looks like it understands in text, but it still cannot cross that boundary?

That uncomfortable gap matters a lot when verifying agent frameworks. With a one-off LLM API call, a plausible natural language answer is often enough to say "it worked." But with Flue, where skills, tools, workflows, and structured output are combined, the model must do more than write an answer. It has to follow the operation sequence required by the framework.

## When I Would Use session.skill

From this experiment, `session.skill()` looks like a good fit for workflows that explicitly execute a skill. Examples include issue triage, review, summarization, and classification tasks where the same criteria should be called repeatedly.

On the other hand, `session.prompt()` is useful when I want to assemble the instruction text flexibly each time. In the existing issue triage workflow, the prompt explicitly said, "Classify this issue and return only the structured result." That form is easy to read and small to implement.

However, if the judgment criteria are managed as a skill, using `session.skill('triage', { args, result })` makes the responsibility boundary clearer. The workflow receives input, passes arguments to the skill, and receives schema-backed data. The classification criteria stay in the Markdown skill. That shape should make it easier to revise the skill later or call the same skill from another workflow.

## What Should Be Evaluated

After this comparison, I think a Flue issue triage eval should separate at least the following checks:

| Axis | What to Check | Example Failure |
| --- | --- | --- |
| Connectivity | Whether model specifier, auth, and base URL work | `Unknown model specifier`, 401, connection failure |
| Protocol | Whether the run reaches `activate_skill` or `finish` | `Agent did not call finish` |
| Schema | Whether the output matches the Valibot schema | Missing required fields, type mismatch |
| Quality | Whether severity and labels are appropriate | Schema is valid, but classification is weak |
| Stability | Whether route or model changes break the result for the same payload | Label or summary drift |

The important distinction is that "the model can be swapped" and "the same execution protocol remains stable" are not the same claim. An OpenAI-compatible endpoint can be configured, the response can come back, natural language can make sense, Flue can reach `finish`, the schema can pass, and the classification can be good enough. Those should be treated as separate stages, not one binary success flag.

## Summary

At least for this issue triage case, `session.skill()` worked fine with structured output. I passed the same payload through `session.prompt(..., { result })` and `session.skill('triage', { args, result })`, and both routes reached `finish` and returned data matching the Valibot schema.

That does not mean every model will behave stably. The earlier `Agent did not call finish` failure happened before structured output schema validation, at the Flue tool protocol layer. Once that distinction is clear, model selection and eval design become much easier to reason about.

Personally, I find it more valuable to preserve "which layer failed" than to keep only clean success logs when verifying Flue. An agent is not just a function that returns natural language. It is an execution actor that has to complete an operation sequence inside a framework. So for a framework like Flue, I want to separate five checks: API request succeeded, model returned output, `finish` was reached, schema validation passed, and the classification was actually good enough.
