> ## Content Index
> Fetch the complete content index at: https://devinside.cloud/llms.txt
> Use this file to discover other available public pages before exploring further.

# Decision APIs in .NET: One Judgment Inside a Working Support Desk
- URL: https://devinside.cloud/decision-apis-01-small-judgment-real-application/
- Published: 2026-11-13T11:04:15.000Z
- Updated: 2026-11-13T21:56:43.000Z
- Description: Give a model one bounded question, which team investigates first, and let C# policy decide the action. A small Agent Framework workflow shows six tickets stopping for six reasons.
- Author: Marcin Borecki
- Tags: AI Engineering, .NET & C#, Series: Decision APIs, #series-decision-apis, #dapi01

**Series:** Decision APIs in .NET, part 1 of 10\. Next: [Part 2, what a decision probability actually computes](https://devinside.cloud/decision-apis-02-how-llms-become-decision-systems/).

A support operator opens ticket T-1042\. The screen shows the customer's message, two verified device fields and an empty reply box:

> The Aurora M4 stopped sending telemetry after firmware 2.8\. Should I return it?

A keyword router sees "return" and sends the ticket to the returns team, who cannot diagnose telemetry. A chat model asked to "handle this ticket" writes a fluent reply that confirms a firmware defect nobody has confirmed. Both failures come from the same place: the application handed an ambiguous sentence to a component that was not asked a precise question, and then let that component decide what happens next.

- Most of a support screen is facts the application already holds. Only one field here needs interpretation: which team should investigate first.
- A decision API answers a bounded question with a value software can consume. It does not take the action; ordinary code does.
- Write the next-action policy in C#, in a fixed order, so missing facts, missing permissions and an unavailable model each stop the case for a visible reason.
- A recorded answer tests the application's branches. It says nothing about whether a model would choose the right team; that needs a separate evaluation set.

## Only one field on the screen needs a model

Start from the result the operator should see, and ask where each value comes from. For T-1042 the useful outcome is a troubleshooting draft that asks for the time of the last telemetry reading and the diagnostic log. It does not claim a firmware defect, and it does not arrange a return.

| Screen element              | Value                                  | Source                              |
| --------------------------- | -------------------------------------- | ----------------------------------- |
| Customer message            | Telemetry stopped; asks about a return | Submitted text                      |
| Device                      | Aurora M4                              | Device record                       |
| Verified firmware           | 2.8                                    | Device record                       |
| Operator may prepare drafts | Yes                                    | Application permission              |
| Team to investigate first   | technical                              | Decision API                        |
| Next action                 | Prepare a troubleshooting draft        | Policy in C#                        |
| Sent to customer            | Nothing                                | The operator sends, not the program |

Only one of the seven rows comes from a model. The device, the firmware and the permission are records the application owns. The next action is a rule the product team owns. Only the team is a judgment about language, so only the team goes to a model.

Architecture

### Three facts come from records. One judgment comes from the model. Code picks the action.

Ticket text customer's own words Device record Aurora M4 Verified firmware 2.8 Operator grant may draft Decision API which team first? Policy ordinary C# 1\. model answered? 2\. grant present? 3\. facts verified? 4\. team supported? technical Troubleshooting draft asks for log and time Operator edits, then sends No send, no return not in this program 

1. The cast: the customer's text, three values the application already stores, one decision API and a policy written in C#.
2. The device, the verified firmware and the operator's permission are records. They go straight to the policy, because nothing about them is uncertain.
3. Only the ticket text goes to the decision API, with one bounded question. The answer is a single value from a fixed set: `technical`.
4. The policy combines the route with the three facts in a fixed order. The model never sees the permission and never chooses the action.
5. The result is a draft in the operator's reply box. Sending it, or arranging a return, does not exist in this program at all.

This split is the whole design. The model's job is narrow enough to evaluate, and everything that follows its answer is software you can test, read and change without touching a prompt.

## A decision API answers a bounded question, not a ticket

A decision API accepts content plus one or more typed questions and returns answers a program can consume directly. Two native interfaces exist at the time of writing, and both have the same three answer kinds:

- **Choice**: one value from a supplied set, with a probability for every candidate and a separate `confidence`.
- **Predicate**: the probability that one proposition about the content is true.
- **Score**: a position on an ordered rubric, returned as a probability-weighted level index plus the per-level distribution.

TypeSafe's System One endpoint (`POST /v1/systemone`) takes a `state` and a `questions` object keyed by names you choose; its yes/no question type is called `noul`. OpenAI's Decisions API (`POST /v1/decisions`) takes an `input` and a `questions` array, and returns `answers` in the same order, where a single question may come back as a `refusal`. Neither response performs anything. They return values; the application decides what those values permit. Part 5 of this series builds native clients for both.

For T-1042 the request needs one question. The JSON below describes our task, not either vendor's wire format:

```json
{
  "caseId": "T-1042",
  "question": "Which team should investigate this ticket first?",
  "content": "The Aurora M4 stopped sending telemetry after firmware 2.8. Should I return it?",
  "choices": ["technical", "billing", "returns", "other", "mixed"]
}
```

The wording of the question is the most important field. Ask "What outcome did the customer mention?" and `returns` becomes the correct answer to the same text. No model can repair a question that permits two incompatible readings; it can only pick one of them, and you will not know which.

The `mixed` choice exists because some tickets need two independent investigations, for example a fault and a duplicated invoice. In this prototype it means "an operator should split the work." Part 3 replaces this list with a versioned task, `support-routing-v1`, which keeps four routes and treats mixed tickets as a labelling problem instead of a fifth team.

The interface in the program follows the question, not the ticket:

```csharp
public sealed record Ticket(
    string Id, string Text, string? Device, string? Firmware, bool MayDraft);

public sealed record Route(string? Team, string Origin);

public interface IRouteReader
{
    Task<Route> ReadAsync(Ticket ticket, CancellationToken cancellationToken);
}
```

`Origin` records where the assessment came from, so a screen or a log can tell a recorded answer from a live one. A null `Team` stands for "no answer" in this first version. It is a deliberate simplification: Part 5 replaces it with distinct outcomes for a refusal, an invalid response, an expired deadline and a failed service, because each one deserves a different next step.

## Keep rules where the question is exact

Not every question about a ticket is a judgment. Whether the Aurora M4 is a supported product is an inventory lookup. A lookup is deterministic, explains its failure ("unknown device code AM-4X") and behaves identically in every language. Asking a model to recall the inventory adds a failure mode without resolving any ambiguity.

Keyword matching is also correct when the task itself is lexical. "Does the message contain incident marker `INC-427`?" is a string search. The trouble starts when a lexical rule is used for a question about intent:

#### Keyword route

```csharp
string team = text.Contains("return", StringComparison.OrdinalIgnoreCase)
    ? "returns"
    : "technical";
// "I do not want a return; help me restore telemetry" -> returns
// "Please arrange the return"                          -> returns
```

Both sentences contain the keyword. Only one is a return request. The rule answers "does the word appear?", which is not the question the desk needs answered.

#### Bounded judgment

```csharp
Route route = await reader.ReadAsync(ticket, ct);
// question: "Which team should investigate first?"
// choices:  technical | billing | returns | other | mixed
// the policy, not the reader, decides what happens next
```

The model interprets the sentence against an explicit question and a closed set of answers. Its output is one value the policy can check.

A useful test for any proposed model call: write down the question in one sentence and list the allowed answers. If a database query or a regular expression answers that sentence exactly, use it. If the hard part is the variety of ways people phrase the same need, the decision API has a specific job to earn.

## Code owns the next action, in an order you chose

The route is an input to the policy, not an instruction. The policy below is the only place that turns an assessment into an action:

```csharp
public enum NextAction { None, DraftReply, AskForEvidence, ManualReview, Unavailable }

public static NextAction Decide(DeskRun r) =>
    r.Route?.Team is null ? NextAction.Unavailable
    : !r.Ticket.MayDraft ? NextAction.ManualReview
    : string.IsNullOrWhiteSpace(r.Ticket.Device)
      || string.IsNullOrWhiteSpace(r.Ticket.Firmware) ? NextAction.AskForEvidence
    : r.Route.Team == "technical" ? NextAction.DraftReply
    : NextAction.ManualReview;
```

Read it from the top. A missing answer never becomes a default team; the screen says the assessment is unavailable. A missing permission stops drafting before anything else is considered. A technical route with an unverified device or firmware asks for that evidence. Only a technical route with both facts and the permission reaches the draft, and every other route goes to a person.

The order is a product decision, so it belongs in code where it can be reviewed and tested. In this version an operator without the drafting permission gets `ManualReview` even when the firmware is also missing. Another product might want both reasons on screen. Either choice is fine; leaving it to prompt wording is not, because the precedence would then change whenever the wording or the model does.

In the missing-firmware scenario the customer's sentence still says "2.8", but the device record has no verified firmware. A model can read "2.8" from the text perfectly and the policy still asks for confirmation. Reading a value from text and verifying it against a record are different operations, and only the second one licenses a troubleshooting template that quotes the version back to the customer.

## Put the steps in an Agent Framework workflow

Microsoft Agent Framework (MAF) provides a graph execution model: executors that receive a message and return one, edges between them, and conditions on those edges. Three direct method calls would serve this program equally well. The reason to use the graph now is that later parts of the series add retrieval, bounded coordination and human review as further nodes and edges, and it is easier to learn the vocabulary on four nodes.

Each executor here receives a `DeskRun` record and returns a new one with one more field filled in. Which branch runs, and how do we know only one did?

Code walkthrough

### Four executors, two complementary conditions, exactly one output per ticket

```csharp
public sealed record DeskRun(Ticket Ticket, Route? Route = null,
    NextAction Action = NextAction.None, string? Draft = null);

public static Workflow Build(IRouteReader reader, TemplateDrafter drafter)
{
    var assess = new Func<DeskRun, IWorkflowContext, CancellationToken,
        ValueTask<DeskRun>>(async (r, _, ct) =>
            r with { Route = await reader.ReadAsync(r.Ticket, ct) })
        .BindAsExecutor("Assess");
    var decide = new Func<DeskRun, DeskRun>(
        r => r with { Action = Decide(r) })
        .BindAsExecutor("Decide");
    var draft = new Func<DeskRun, DeskRun>(
        r => r with { Draft = drafter.Draft(r.Ticket) })
        .BindAsExecutor("Draft");
    var hold = new Func<DeskRun, DeskRun>(r => r)
        .BindAsExecutor("Hold");

    return new WorkflowBuilder(assess)
        .AddEdge(assess, decide)
        .AddEdge<DeskRun>(decide, draft,
            r => r?.Action == NextAction.DraftReply)
        .AddEdge<DeskRun>(decide, hold,
            r => r is not null && r.Action != NextAction.DraftReply)
        .WithOutputFrom(draft, hold)
        .Build();
}

// Harness: one run per ticket.
await using Run run = await InProcessExecution.RunAsync(
    workflow, new DeskRun(ticket));
WorkflowEvent[] events = run.NewEvents.ToArray();
WorkflowOutputEvent output = events.OfType<WorkflowOutputEvent>().Single();
DeskRun result = (DeskRun)output.Data!;
```

1. The state that flows through the graph is one immutable record. Each step adds a field with `with`, so the final record shows everything that happened.
2. Assess is the only executor that calls the route reader. It is also the only place a live model client will be plugged in later.
3. Decide calls the policy from the previous section. The graph does not contain business rules of its own.
4. Draft prepares text; Hold passes the record through untouched. A held case therefore cannot invoke the drafter by construction.
5. The two edge conditions are complementary for any non-null record, so exactly one branch runs after Decide.
6. Only Draft and Hold are registered as output sources. Their return values surface as `WorkflowOutputEvent`; Assess and Decide never produce outputs.
7. `NewEvents` returns the events since its last access, so the harness reads it once into an array, then requires a single output.

I compiled and ran this program on .NET 10 against `Microsoft.Agents.AI.Workflows` 1.28.0\. The `.Single()` call is an assertion, not decoration: if a future edge made both conditions true for some record, the harness would fail on the first such ticket instead of quietly producing two answers.

## Six tickets, one graph, four different stops

The interesting behavior is not the happy path. It is how the same graph stops for different reasons. Slide through the six scenarios below: each changes one input and the policy picks a different action.

Explorable

### Only one of six tickets reaches the drafter

T-1042 The Aurora M4 stopped sending telemetry after firmware 2.8\. Should I return it? Route from decision API technical Verified firmware 2.8 Operator grant may draft Assess reads route Decide policy in C# Draft template Hold no draft drafts 1 Next action DraftReply 

1. T-1042: technical route, verified firmware, drafting permission. The only case that reaches Draft, and the drafter is called exactly once.
2. The text still says 2.8, but the record has no verified firmware. The policy asks for evidence instead of quoting an unverified version.
3. Same route and facts, but this operator may not prepare drafts. The permission check comes first, so the case goes to manual review.
4. A fault plus a duplicated invoice. One route cannot express two investigations, so a person splits the ticket.
5. The assessment failed. The screen says so instead of guessing a team, and no default department hides the failure.
6. The model correctly recognizes a return request, but this program has no returns workflow. A correct label cannot create a missing capability.

Scenario inputs are the six recorded tickets from the program in this article; the actions and draft counts are its actual output.

The run printed one line per ticket. Every action and every draft count matched the expected table, together with the two extra checks described below, for 20 of 20 checks passed:

```text
complete     route=technical  action=DraftReply      drafts=1 output-from=Draft
  draft: Thanks. Your Aurora M4 on firmware 2.8 stopped sending telemetry. Please send
         the time of the last reading and the diagnostic log so we can investigate.
no-firmware  route=technical  action=AskForEvidence  drafts=0 output-from=Hold
no-grant     route=technical  action=ManualReview    drafts=0 output-from=Hold
mixed        route=mixed      action=ManualReview    drafts=0 output-from=Hold
unavailable  route=(none)     action=Unavailable     drafts=0 output-from=Hold
return       route=returns    action=ManualReview    drafts=0 output-from=Hold
20/20 checks passed
```

Two rows deserve a second look. The return row shows that classification quality and product capability are separate: improving the model cannot give this program a returns workflow, so the correct label still ends with a person. The mixed row shows the limit of a single route. If mixed tickets are common, ask independent questions ("does this need technical work?", "does this need billing work?") or create sub-cases. A catch-all route only moves the problem to whoever reads that queue.

## A recorded answer tests the application, not the model

The route reader in this program is `RecordedRouteReader`: a dictionary from an exact `Ticket` record to the `Route` written for it. It throws for any ticket it does not know. Records compare by value, so changing a single character of the complaint produces a different key and an exception, not the old answer.

```csharp
public sealed class RecordedRouteReader(IReadOnlyDictionary<Ticket, Route> recorded) : IRouteReader
{
    public Task<Route> ReadAsync(Ticket ticket, CancellationToken cancellationToken) =>
        recorded.TryGetValue(ticket, out var route)
            ? Task.FromResult(route)
            : throw new InvalidOperationException($"No recorded answer for this exact ticket {ticket.Id}.");
}
```

That refusal matters. Without it, a reader could edit the complaint into a return request, see `technical` again and conclude the system "understood" something. The program checks it explicitly: the T-1042 ticket with its text replaced is rejected. A second extra check pins the precedence rule, a ticket with neither grant nor firmware gets `ManualReview`.

The drafter is a template, not a model call, which makes the strongest assertions in the program possible: the complete case calls it exactly once, and each held case calls it zero times. Those are control-flow facts, independent of how good any language model is.

When a live client replaces the recording, keep the recorded scenarios. They remain the regression suite for the policy and the graph. Add a separate, labelled evaluation set for the model's routing quality. The first proves that `technical` produces a draft; only the second can show how often a model returns `technical` when it should.

It is tempting to display "technical, 0.93" to make the screen look rigorous. A recorded answer has no calibrated distribution behind it, and even a live probability means "93 out of 100 comparable tickets are technical" only after you have checked it on your own data. Part 2 explains what such a number computes; Part 4 derives thresholds from the cost of each kind of mistake.

## Pitfalls in a first decision-backed feature

- **A default team on failure.** Mapping an exception or a timeout to `technical` makes failures look like decisions. Represent "no answer" explicitly and show it.
- **Letting the model choose the action.** "Route this ticket and reply" merges a judgment with an effect. Ask for the judgment; keep the effect behind a policy and a permission.
- **Treating extracted values as verified.** A model reading "firmware 2.8" from text has not checked the device record.
- **A fixture that ignores its input.** A recording that returns the same answer for any text will pass every test and prove nothing about routing. Key it on the exact input and fail otherwise.
- **Adding a catch-all route.** `other` or `mixed` can quietly become the place where the taxonomy's problems go. Count how often it is used, and read those tickets.

## Which tool answers which question

| Question about the ticket                 | Use                                               | Why                                                                  |
| ----------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------- |
| Is the Aurora M4 a supported product?     | Inventory lookup                                  | Exact, explainable, no ambiguity to resolve                          |
| Does the text contain INC-427?            | String or regex match                             | The question is lexical                                              |
| Which team should investigate first?      | Decision API choice, or a trained classifier      | Many phrasings of the same need; fixed answer set                    |
| Is there enough evidence to troubleshoot? | Predicate with a probability                      | A yes/no proposition whose threshold depends on the cost of mistakes |
| What should the reply say?                | Template, or a generative model with human review | Open text for a person to edit, not a value for code                 |
| What happens next?                        | Policy in C#                                      | Product rules need tests and code review, not prompt wording         |

## Checklist

- Every field on the screen has a named source: record, permission, model or rule.
- Each model call asks one question in one sentence, with a closed list of answers.
- The next action is computed by code in an order the product team agreed, with a test for the precedence.
- "No answer" is a visible state, never a default team.
- Values read from text are verified against records before the program relies on them.
- Recorded answers are keyed on the exact input and kept as regression tests after a live model arrives.
- Routing quality is measured on a separate labelled set, not inferred from passing tests.

## What to build next

This program is a useful first completion point: an operator-facing result, one explicit place where interpretation happens and deterministic control over everything after it. Making it fit for live support is a list of concrete steps, and the rest of the series takes them in order. Part 2 opens the box and asks what a returned probability is a probability of. Part 3 turns the route question into a versioned contract with a validator. Part 4 chooses actions from the cost of each error, and Part 5 replaces the recording with native clients.

If a rule already answers the real question, keep the rule. When the hard part is language, give the model exactly that part, and let ordinary software do the rest. The same principle sits behind [putting typed intent between language and SQL](https://devinside.cloud/one-question-several-queries-put-typed-intent-between-language-and-sql/) and [treating an agent tool as an API with a different caller](https://devinside.cloud/an-agent-tool-is-an-api-with-a-different-caller/).

**Series:** Next: [Part 2, what a decision probability actually computes](https://devinside.cloud/decision-apis-02-how-llms-become-decision-systems/).

## Further reading

- [OpenAI: Decisions API guide](https://developers.openai.com/api/docs/guides/decisions?ref=devinside.cloud) and [reference for POST /v1/decisions](https://developers.openai.com/api/reference/resources/decisions/methods/create?ref=devinside.cloud)
- [TypeSafe: System One API reference](https://docs.typesafe.ai/api/system-one?ref=devinside.cloud)
- [Microsoft Learn: Agent Framework workflows (executors, edges, events)](https://learn.microsoft.com/en-us/agent-framework/concepts/workflows/?ref=devinside.cloud)
- [NuGet: Microsoft.Agents.AI.Workflows 1.28.0](https://www.nuget.org/packages/Microsoft.Agents.AI.Workflows/1.28.0?ref=devinside.cloud)