Tutorial 113

Ein KI-Assistent, der die Use Cases einer Anwendung bedient — mit den Berechtigungen des angemeldeten Nutzers und nicht mehr.

Tutorial 77 zeigt die Mechanik der Tool-Schleife (Rechner, Dateien). Dieses Tutorial zeigt die Konventionen: wie eine echte Fachanwendung ihre Use Cases anbindet, wie die Autorisierung ausgeliefert wird und was verhindert, dass ein Modell etwas ändert, das niemand verlangt hat.

Aufbau

Das Beispiel folgt dem Profil go_nago_ddd1 aus speclink — dem Traceability-Werkzeug, mit dem worldiety nago-Projekte prüft. Es weicht damit bewusst von den übrigen Tutorials ab, die alles in eine main.go legen: Genau diese Struktur ist der Grund, warum der Assistent weiter unten ohne einen einzigen Adapter auskommt.

cmd/ai-example/main.go        Einstiegspunkt: Bootstrap und Verdrahtung, keine Fachlichkeit
app/library/                  der Bounded Context — was das System tut
  model.go                    Aggregat, Filter, Request, Result
  perm.go                     eine Berechtigung je Use Case
  repository.go               was der Kontext zum Speichern braucht, nicht wie
  usecases.go                 das UseCases-Bündel
  uc_find_all_books.go        ein Use Case je Datei, Typ und Konstruktor zusammen
  uc_lend_book.go
  uc_return_book.go
  uc_*.annotation.go          bindet den Use Case an seine Anforderung
  ui/page_books.go            package uilibrary — die Ansicht für Menschen
  ai/tools.go                 package ailibrary — die Ansicht für ein Modell
  cfg/cfg.go                  package cfglibrary — die einzige Stelle, an der sich beides trifft
requirements/
  dec/R-DEC-*.spec.go         die Entscheidungen: warum die Bibliothek so funktioniert
  fun/library/R-LIB-*.spec.go was sie leisten muss

Die Abhängigkeiten zeigen ausschließlich nach innen: ui und ai kennen library, library kennt keinen von beiden. Ein Kontext, der seine eigene Oberfläche importiert, ist ohne Renderer nicht mehr testbar.

ai/ steht bewusst neben ui/, nicht darin: Ein Modell ist eine Art, diesen Kontext zu erreichen, genau wie ein Bildschirm eine ist. Keines von beidem gehört zur Domäne.

Ausführen

Weil der Einstiegspunkt unter cmd/ liegt, ist der Aufruf einen Pfad länger als bei den anderen Tutorials:

go run go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/cmd/ai-example@latest

Was hier fehlt

Das Beispiel nutzt github.com/worldiety/speclink/spec — ein Modul ohne jede Abhängigkeit, das nur die Deklarationen enthält. Das Werkzeug speclink selbst (und damit speclink.json, speclink verify und die statische Prüfung) ist nicht eingebunden: Es ist ein Compiler-Frontend und hat im Modulgraph einer Anwendung nichts zu suchen.

Die Anforderungen werden hier also deklariert und zur Laufzeit gelesen, aber nicht statisch geprüft. In einem echten Projekt kommt das dazu.

Warum die Tools ohne Adapter funktionieren

Das ist der Bogen, um den es geht. Ein Use Case unter go_nago_ddd1 hat die Form

func(subject auth.Subject, cmd In) (Out, error)

Das ist keine Bequemlichkeit für die KI, sondern die Architekturregel des Projekts — das Subject ist ein Parameter, damit der Aufrufer entscheiden muss, wer da handelt, statt einen Context durchzureichen, der nichts entscheidet.

Und es ist zugleich exakt die Form, die ein Tool braucht. completion.NewUseCaseTool nimmt sie unverändert entgegen:

lend := completion.NewUseCaseTool("lend_book", "…", uc.LendBook)

Kein Adapter, kein Subject am Konstruktor, kein Neuaufbau pro Anfrage. Wer seine Anwendung anders schneidet, schreibt für jedes Tool einen Wrapper — und muss die Autorisierung ein zweites Mal von Hand hinschreiben.

Die sieben Regeln

Diese Regeln sind der eigentliche Inhalt. Alles andere folgt aus ihnen.

1. Ein Tool geht durch einen Use Case, nie durch ein Repository

Das ist kein Ordnungsprinzip, sondern das gesamte Autorisierungsmodell. Ein nago-Use-Case prüft das übergebene Subject — dasselbe, das auch ein Klick in der UI prüfen würde. completion.NewUseCaseTool reicht das Subject des fragenden Nutzers durch, also kann der Assistent nichts lesen, was dieser Nutzer nicht lesen darf, und nichts schreiben, was er nicht schreiben darf.

Ein Tool, das am Use Case vorbei ins Repository greift, hebelt das aus — und zwar unbemerkt, weil es funktioniert.

2. Use Cases brauchen keinen Wrapper

Ein nago-Use-Case hat die Form func(auth.Subject, Request) (Response, error). Genau diese Form nimmt NewUseCaseTool entgegen:

lend := completion.NewUseCaseTool("lend_book",
    "Leiht ein Exemplar eines Buches an eine Person aus.",
    uc.LendBook)

Kein Adapter, kein Subject-Parameter am Konstruktor, kein Neuaufbau pro Anfrage. Die Tools werden einmal beim Start gebaut und von jedem Fenster und jedem Nutzer geteilt.

Für die zweite übliche Form — func(auth.Subject, Filter) iter.Seq2[T, error] — gibt es NewSeqTool (siehe Regel 4). Wer wirklich eine eigene Funktion braucht, nimmt NewSubjectTool.

3. Schreibende Tools werden markiert, nicht beschrieben

lend := completion.NewUseCaseTool(...).
    AsMutating("gibt ein Exemplar heraus und trägt die Person als Ausleiher ein")

Ein Satz im System-Prompt („frag immer nach, bevor du schreibst") ist eine Bitte an das Modell. Die Markierung ist ein Gatter davor: ConfirmMutations hält den Aufruf an und zeigt Werkzeug, Wirkung und die vom Modell gewählten Argumente, bevor irgendetwas passiert. ReadOnly — vom Betreiber in den Einstellungen umlegbar — entfernt schreibende Tools ganz, das Modell erfährt nicht einmal, dass es sie gibt.

Der Text in AsMutating ist das, was der Mensch vor dem Bestätigen liest. Ein leerer Text macht den Dialog wertlos.

4. Listen werden begrenzt

list := completion.NewSeqTool("list_books", "…", uc.FindAllBooks)

NewSeqTool ergänzt das Schema um limit (Standard 200, hart 1000) und liefert truncated samt Klartext-Hinweis. Ohne das reicht ein FindAll über ein paar tausend Datensätze, um das Kontextfenster zu sprengen — und der Fehler sieht dann aus wie ein Provider-Problem, nicht wie eine fehlende Grenze.

Wichtiger noch: eine stillschweigend gekürzte Liste ist schlimmer als eine offen gekürzte. Das Modell soll den Filter enger ziehen, nicht aus einer halben Antwort schließen.

5. Namen und Beschreibungen sind Teil der Schnittstelle

Namen sind lower_snake_case, Beschreibungen sind für jemanden geschrieben, der die Anwendung nie gesehen hat. Beides wird beim Bauen geprüft: ein falscher Name oder eine leere Beschreibung ist ein Panic beim Start, kein ratloses Modell zur Laufzeit.

Dasselbe gilt für die desc-Tags am Request-Typ — sie sind die Dokumentation, die das Modell liest:

type BookFilter struct {
    Query         string `json:"query" desc:"optional; case-insensitive substring matched against title and author"`
    OnlyAvailable bool   `json:"onlyAvailable" desc:"when true, only books with at least one free copy are returned"`
}

6. Der Domain-Typ ist der Tool-Typ

Ein eigenes DTO neben dem Aggregat ist meistens überflüssig. Die desc-Tags gehören an den Domain-Typ — genauso wie label-Tags dort stehen, wo form.Auto sie liest:

type Book struct {
    ID     BookID   `json:"id" desc:"stable identifier, used when lending or returning"`
    Copies int      `json:"copies" desc:"total number of copies owned, lent out ones included"`
    LentTo []string `json:"lentTo,omitempty" desc:"names of the people currently holding a copy; …"`
}

Das funktioniert technisch bereits vollständig: benannte String-Typen (type BookID string) werden als string abgebildet, unexportierte Felder übersprungen, time.Time als date-time.

Ein DTO ist dann richtig, wenn das Modell etwas braucht, das der Domain-Typ nicht hat: einen für Menschen formatierten Wert (time.Duration → „8 Stunden"), ein über mehrere Datensätze berechnetes Aggregat, oder eine bewusst reduzierte Sicht. Nicht, um Felder umzubenennen.

Pflicht vs. optional: Ein Feld ist standardmäßig Pflicht. Ein Filter ist das Gegenteil davon — dort gehört optional:"true" an jedes Feld:

type BookFilter struct {
    Query string `json:"query" optional:"true" desc:"…"`   // Filter: engt ein
}
type LendRequest struct {
    Book BookID `json:"book" desc:"…"`                     // Request: befiehlt
}

Ein Filter, dessen Felder als Pflicht gemeldet werden, zwingt das Modell, bei jedem Aufruf Werte für alle zu erfinden — und es tut es, weil das Schema es so verlangt.

7. Rückgabeform nur beschreiben, wo sie nicht offensichtlich ist

Kein Provider akzeptiert ein Output-Schema für Tools. Anthropic und die OpenAI-kompatiblen APIs kennen nur name, description und ein Input-Schema. Die einzige Stelle, an der eine Beschreibung der Rückgabe ankommen kann, ist der Beschreibungstext — und der wird bei jeder Anfrage für jedes Tool mitgeschickt.

Deshalb ist .WithResultDoc() ein bewusster Opt-in:

list := completion.NewSeqTool("list_books", "…", uc.FindAllBooks).
    WithResultDoc()   // wegen truncated: das kann das Modell den Daten nicht ansehen

lend := completion.NewUseCaseTool("lend_book", "…", uc.LendBook).
    AsMutating("…")   // kein WithResultDoc: summary + available sprechen für sich

Wo Feldnamen für sich sprechen, lernt das Modell die Form am ersten echten Ergebnis — kostenlos.

Konsequenz, die man kennen muss: Ohne WithResultDoc() erreichen desc-Tags am Rückgabetyp das Modell nie. Sie sind dann toter Code.

Berechtigungen: zwei Rollen, nicht eine

Der Assistent braucht drei Framework-Berechtigungen (Provider auflisten, Modelle auflisten, Sitzung anlegen). Diese nicht in die Fachrolle schreiben. cfgai.Enable deklariert dafür eine eigene Systemrolle:

cfgai.RoleAssistantUser // "nago.ai.assistant.user"

Rolle zuweisen → Knopf erscheint. Die Fachrolle bleibt davon unberührt, und der Assistent kann trotzdem nur, was die Fachrolle erlaubt.

Die eigene Fachrolle liefert man genauso aus:

cfg.DeclareSystemRole(role.Role{ID: RoleLibrarian, Name: "Bibliothekar", }, LibrarianPermissions()...)

Eine Systemrolle lässt sich weder löschen noch in ihren Berechtigungen bearbeiten — beides würde beim nächsten Start ohnehin rückgängig gemacht. Name und Beschreibung darf der Betreiber ändern, und ein Neustart überschreibt das nicht.

Warum das einen Test verdient: Das Bootstrap-Konto hat konstruktionsbedingt jede Berechtigung. Es fällt also niemandem auf, wenn eine ausgelieferte Rolle keine davon gewährt — bis der erste echte Nutzer eine leere Seite sieht. app/library/cfg/roles_test.go schreibt die Berechtigungs-IDs deshalb als Literale aus. Ein Test, der seine Erwartung aus dem geprüften Code ableitet, stimmt jeder Änderung zu, auch der falschen.

Kontext: wo der Nutzer steht

Routen beschreiben sich bei der Registrierung selbst:

// app/library/cfg/cfg.go
cfg.RootViewWithDecoration(pages.Books, func(wnd core.Window) core.View {
    return uilibrary.PageBooks(wnd, uc)
}, application.Purpose(
    "Den Bestand der Bibliothek durchsehen: welche Titel es gibt, wie viele Exemplare frei sind …"))

uicompletion.WindowContext(wnd) macht daraus den situativen Teil des Prompts — Route und Zweck, die Parameter, mit denen die Seite geöffnet wurde, und die Berechtigungen des Nutzers. Alles aus dem, was das Framework ohnehin weiß, also driftet es nicht weg wie eine handgepflegte Liste von Bildschirmen.

Der fachliche Teil des Prompts bleibt davon getrennt:

SystemPromptFunc: func() string {
    return ailibrary.SystemPrompt + "\n\n" + uicompletion.WindowContext(wnd)
}

Anforderungen als Werkzeug: „warum ist das so?"

Jedes Fachwerkzeug beantwortet eine Frage über Daten. Keines beantwortet die Frage, die jemand tatsächlich hat, wenn das System etwas ablehnt:

„Von Der Prozess ist derzeit kein Exemplar frei." — Warum kann ich mich dann nicht vormerken lassen?

Ohne ein Werkzeug dafür verweigert das Modell nicht etwa die Antwort — es erfindet eine Begründung, flüssig und plausibel. Eine erfundene Regel ist schlimmer als Schweigen: Sie klingt wie das System, das über sich selbst spricht.

nago liefert das fertig mit

Anforderungen werden mit spec.Declare deklariert und landen damit in einem Laufzeitkatalog:

// requirements/dec/R-DEC-AVAILABILITY.spec.go
var RDecAvailability = spec.Declare(spec.Requirement{
    ID:           "R-DEC-AVAILABILITY",
    Kind:         spec.Decision,
    Status:       spec.Normative,
    Title:        "Verfügbarkeit wird berechnet, nicht gespeichert",
    Text:         "Die Zahl der freien Exemplare ergibt sich aus Copies minus der Länge von LentTo.",
    Rationale:    "Ein abgeleiteter Wert, der zusätzlich gespeichert wird, kann sich mit sich selbst widersprechen.",
    Consequences: "Jede Anzeige rechnet neu; eine Auswertung über zehntausend Titel geht nicht über einen Index.",
})

Die Annotationsdatei verbindet Use Case und Anforderung — und liegt im normalen Build, bricht also, wenn der Use Case verschwindet:

// app/library/uc_lend_book.annotation.go
var _ = spec.For[LendBook](
    spec.Satisfies(fun.RLibLend, dec.RDecBorrower),
    spec.Help("Gibt ein Exemplar an eine Person heraus. Ist keines frei, wird die Ausleihe abgelehnt."),
)

Und das war die ganze Arbeit. Die Werkzeuge kommen aus dem Framework:

specMod := option.Must(cfgspeclink.Enable(cfg))

tools := append(ailibrary.Tools(lib.UseCases), aispeclink.Tools(specMod.UseCases)...)

SystemPromptFunc: func() string {
    return ailibrary.SystemPrompt + "\n\n" +
        aispeclink.Index(wnd.Subject(), specMod.UseCases) + "\n" +
        uicompletion.WindowContext(wnd)
}

aispeclink.Tools liefert list_requirements, read_requirement und read_capabilities; Index rendert je eine Zeile für den Prompt. Es gibt in diesem Beispiel keine handgeschriebene Wissensschicht — und genau darum geht es: Wer den Katalog von Hand nachbaut, pflegt eine Kopie, die abdriftet, ohne dass etwas bricht.

Zustand ist nicht Existenz

R-LIB-RESERVATION steht bewusst auf planned und ist an nichts gebunden. speclink verlangt eine Bindung nur für normative Anforderungen — eine Sicht, die nur die Bindungen liest, würde sie also gar nicht sehen, und der Assistent antwortete „so etwas gibt es nicht" statt „das ist noch nicht umgesetzt".

Genau deshalb liest nago den Katalog und nicht die Bindungsregistrierung. Die speclink-Dokumentation nennt diese Falle ausdrücklich.

Wer welche Anforderung sehen darf

spec.Requirement hat ein Feld Disclosure (public, internal, confidential, secret). speclink sagt dazu klar: „Nothing enforces it… it is not a control." Die Durchsetzung ist Sache dessen, der den Text jemandem zeigt — also nagos.

Stufeohne nago.speclink.requirement.read_internalmit
publicsichtbarsichtbar
internal, confidentialausgefiltertsichtbar
secretnienur einzeln, nie in einer Liste

Letzteres setzt die Definition wörtlich um: „disclosed individually and never in bulk." Eine Anforderung, die der Nutzer nicht sehen darf, wird als nicht vorhanden gemeldet — die Auskunft „gibt es, darfst du aber nicht" verrät bereits mehr, als das Erraten einer Kennung einbringen sollte.

Die Rolle nago.speclink.reader bündelt die drei Lese-Berechtigungen; read_internal ist bewusst nicht darin, weil das eine Entscheidung über eine Person ist und nicht über eine Funktion.

Nebenbei: eine Verwaltungsseite

cfgspeclink.Enable registriert außerdem admin/speclink/requirements. Dieselbe Liste, dieselben Use Cases, dieselben Disclosure-Regeln — was ein Betreiber dort sieht und was der Assistent sagen darf, ist damit konstruktionsbedingt dieselbe Menge und nicht bloß per Absprache.

Der Knopf

Provider-Auflösung, Modellwahl, Einstellungen, Cache und Diagnose liefert cfgai mit:

cfg.SetDecorator(func(wnd core.Window, view core.View) core.View {
    return modAI.Assistant.Decorate(wnd, scaffold(wnd, view), cfgai.AssistantOptions{})
})

Am Decorator statt an einzelnen Seiten, damit der Assistent wirklich überall ist — auch auf den Verwaltungsseiten des Frameworks. Kann er nicht laufen (kein Provider, kein Modell, ausgeblendet, Rolle fehlt), kommt die Ansicht unverändert zurück und der Grund landet einmal im Log. Ein fehlender Token darf kein Banner werden, das den Nutzer durch die Anwendung verfolgt.

Ausprobieren

Bootstrap-Admin: das Passwort steht in cmd/ai-example/main.go. Danach unter Verwaltung → Tresor einen Provider-Token hinterlegen und den Nutzern die Rollen „Bibliothekar" und „AI Assistant User" zuweisen.

Fragen zum Testen:

  • „Was ist von Kafka da?" — eine Leseabfrage
  • „Leih Die Verwandlung an Bernd aus." — der Bestätigungsdialog erscheint; einmal ablehnen und beobachten, dass das Modell die Absage aufgreift statt abzustürzen
  • In den Einstellungen Nur lesender Zugriff setzen und erneut ausleihen lassen — das Modell kennt das Werkzeug dann nicht mehr
  • „Warum steht bei den Ausleihern nur ein Name und kein Benutzerkonto?" — das Modell schlägt R-DEC-BORROWER nach und nennt auch, was die Entscheidung kostet, statt sich etwas auszudenken
  • „Kann ich ein ausgeliehenes Buch vormerken?" — die Antwort ist „noch nicht", nicht „gibt es nicht": R-LIB-RESERVATION steht auf planned
  • „Was kann ich hier eigentlich machen?" — Orientierung über read_capabilities

Example

Die Domäne — Aggregat, Berechtigungen, ein Use Case und das Bündel:

// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

// Package library is a bounded context: it says what the system does, and nothing about how it is reached.
//
// It knows nothing about the user interface in ui/ and nothing about the AI tools in ai/ - both are ways of
// reaching this context, and the dependency only ever points inwards. That is what lets the assistant be
// built from these use cases without a single adapter: a use case already has the shape a tool needs, and it
// already audits the acting subject.
package library

// BookID identifies a book in the library.
type BookID string

// Book is a title the library owns, in some number of copies.
//
// This aggregate is handed to the model as it is - there is no separate DTO next to it. The `desc` tags are
// what makes that work: they are the documentation the model reads, exactly as `label` tags are what
// form.Auto reads. A second type that had to be kept in sync with this one would buy nothing and would
// eventually drift.
//
// Write a DTO when the model needs something this type does not have: a value formatted for a human, an
// aggregate computed across records, or a subset that deliberately hides a field. Not merely to rename
// things.
type Book struct {
	ID     BookID `json:"id" desc:"stable identifier, used when lending or returning"`
	Title  string `json:"title"`
	Author string `json:"author"`
	// Copies is how many the library owns in total.
	Copies int `json:"copies" desc:"total number of copies owned, lent out ones included"`
	// LentTo lists the borrowers currently holding a copy.
	//
	// The number of free copies is Copies minus the length of this list. It is not a field of its own on
	// purpose: a derived value that is also stored is a value that can disagree with itself.
	LentTo []string `json:"lentTo,omitempty" desc:"names of the people currently holding a copy; a book is fully lent out when this has as many entries as there are copies"`
}

// Identity makes the book an aggregate root.
func (b Book) Identity() BookID { return b.ID }

// WithIdentity is required by the repository.
func (b Book) WithIdentity(id BookID) Book {
	b.ID = id
	return b
}

// String is what a picker displays.
func (b Book) String() string { return b.Title }

// Available is how many copies can still be lent out.
func (b Book) Available() int {
	return b.Copies - len(b.LentTo)
}

// BookFilter narrows a listing.
//
// Every field is optional: a filter narrows a listing, it does not command one. The `optional` tags say so to
// a model that is handed this type as a tool input - without them the schema would demand all three on every
// call, and the model would dutifully invent them.
//
// The `desc` tags are not decoration either: they are the documentation the model reads to decide how to call
// this. Write them for a colleague who has never seen the application.
type BookFilter struct {
	Query         string `json:"query" optional:"true" desc:"case-insensitive substring matched against title and author"`
	OnlyAvailable bool   `json:"onlyAvailable" optional:"true" desc:"when true, only books with at least one free copy are returned"`
	Borrower      string `json:"borrower" optional:"true" desc:"only books currently lent to this borrower"`
}

// LendRequest asks for one copy of a book.
//
// Both fields are mandatory and neither carries `optional`, so the schema tells the model as much before it
// ever calls. That is the difference to [BookFilter]: a filter narrows, a request commands.
type LendRequest struct {
	Book     BookID `json:"book" desc:"the id of the book, as returned by list_books"`
	Borrower string `json:"borrower" desc:"the name of the person receiving the copy"`
}

// LendResult reports in words what happened, because that is what both a human and a model need back.
type LendResult struct {
	Summary   string `json:"summary"`
	Available int    `json:"available" desc:"copies still available after this operation"`
}
// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

package library

import (
	"go.wdy.de/nago/application/permission"
)

// Permissions of the library, one per use case and bound to it through the type parameter.
//
// One per use case is what makes authorisation assignable and auditable: an operator hands out the ability to
// lend without also handing out the ability to take back. A permission covering several use cases can only be
// granted or withheld wholesale.
//
// The texts come from the framework's translation catalogue via the Declare<Verb> helpers rather than being
// written here, because these strings appear in the role editor, where a non-developer decides who may do
// what - in their own language.
//
// This is also the entire authorisation model of the assistant. The tools in ai/ call these use cases, the
// use cases audit these permissions against the acting subject, and so the assistant can never do more than
// the person operating it.
var (
	PermFindAllBooks = permission.DeclareFindAll[FindAllBooks]("tutorial.library.book.find_all", "Buch")
	PermLendBook     = permission.DeclareUpdate[LendBook]("tutorial.library.book.lend", "Buch")
	PermReturnBook   = permission.DeclareUpdate[ReturnBook]("tutorial.library.book.return", "Buch")
)
// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

package library

import (
	"fmt"
	"strings"

	"go.wdy.de/nago/auth"
)

// LendBook hands one copy of a book to a person.
type LendBook func(subject auth.Subject, req LendRequest) (LendResult, error)

// NewLendBook builds the lending use case.
func NewLendBook(repo BookRepository) LendBook {
	return func(subject auth.Subject, req LendRequest) (LendResult, error) {
		if err := subject.Audit(PermLendBook); err != nil {
			return LendResult{}, err
		}

		borrower := strings.TrimSpace(req.Borrower)
		if borrower == "" {
			return LendResult{}, fmt.Errorf("ohne Namen kann kein Exemplar herausgegeben werden")
		}

		optBook, err := repo.FindByID(req.Book)
		if err != nil {
			return LendResult{}, err
		}

		if optBook.IsNone() {
			return LendResult{}, fmt.Errorf("kein Buch mit der Kennung %q", req.Book)
		}

		book := optBook.Unwrap()
		if book.Available() <= 0 {
			return LendResult{}, fmt.Errorf("von %q ist derzeit kein Exemplar frei", book.Title)
		}

		book.LentTo = append(book.LentTo, borrower)
		if err := repo.Save(book); err != nil {
			return LendResult{}, err
		}

		// The result says what happened in words. A caller that is a model needs that as much as a human
		// does: it has to report back, and it must not have to reconstruct the outcome from the arguments it
		// sent.
		return LendResult{
			Summary:   fmt.Sprintf("%q an %s ausgeliehen.", book.Title, borrower),
			Available: book.Available(),
		}, nil
	}
}
// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

package library

// UseCases bundles the capabilities of the library context.
//
// Callers depend on this bundle rather than on the internals, and [NewUseCases] is the single place where the
// repository is threaded through. The screens in ui/ and the tools in ai/ both take it, and neither can reach
// past it into the store.
type UseCases struct {
	FindAllBooks FindAllBooks
	LendBook     LendBook
	ReturnBook   ReturnBook
}

// NewUseCases wires the library use cases.
func NewUseCases(repo BookRepository) UseCases {
	return UseCases{
		FindAllBooks: NewFindAllBooks(repo),
		LendBook:     NewLendBook(repo),
		ReturnBook:   NewReturnBook(repo),
	}
}

Eine Anforderung und die Annotation, die sie mit dem Use Case verbindet:

// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

// Package dec holds the recorded decisions of this example.
//
// One requirement per file, the file named after the identity. spec.Declare puts it into the runtime
// catalogue, which is what lets the running program - and the assistant in it - explain itself. Without that
// call the value exists only for the static tool, and Go offers no reflection over package level variables
// to find it again.
package dec

import "github.com/worldiety/speclink/spec"

var RDecAvailability = spec.Declare(spec.Requirement{
	ID:         "R-DEC-AVAILABILITY",
	Kind:       spec.Decision,
	Discipline: spec.Technical,
	Status:     spec.Normative,
	Title:      "Verfügbarkeit wird berechnet, nicht gespeichert",
	Text:       "Die Zahl der freien Exemplare ergibt sich aus Copies minus der Länge von LentTo und wird nirgends abgelegt.",
	Rationale:  "Ein abgeleiteter Wert, der zusätzlich gespeichert wird, ist ein Wert, der sich mit sich selbst widersprechen kann. Genau das passiert beim ersten Absturz zwischen zwei Schreibvorgängen, und danach glaubt niemand mehr der Zahl.",
	// Mandatory for a decision, and the half nobody writes unprompted: a justification is pleasant to write
	// and gets written at length, while admitting what the ruling makes worse does not.
	Consequences: "Jede Anzeige rechnet neu. Eine Auswertung über zehntausend Titel lässt sich nicht über einen Index beantworten, sondern muss den Bestand lesen.",
})
// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

package library

import (
	"github.com/worldiety/speclink/spec"
	"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/dec"
	fun "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/fun/library"
)

var _ = spec.For[LendBook](
	spec.Satisfies(fun.RLibLend, dec.RDecBorrower),
	spec.Help("Gibt ein Exemplar an eine Person heraus. Ist keines frei, wird die Ausleihe abgelehnt — eine Vormerkung gibt es noch nicht."),
)

Die Ansicht für ein Modell:

// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

// Package ailibrary exposes the library to a language model.
//
// It sits beside ui/ rather than inside the context on purpose: a model is a way of reaching this context,
// exactly as a screen is, and neither belongs to the domain. The dependency points inwards only - ailibrary
// imports library, and library imports neither.
//
// That placement is also the answer to the question this example exists for. Because the tools call use
// cases, and a use case audits the acting subject, the assistant is bounded by the permissions of whoever is
// operating it. There is no second authorisation model to write, and none to forget.
package ailibrary

import (
	"go.wdy.de/nago/application/ai/completion"
	"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library"
)

// Tools exposes the library to the model.
//
// Note what is not here: no wrapper around the use cases, no subject parameter, no per-request rebuild. A
// nago use case has the shape func(auth.Subject, Request) (Response, error), which is exactly the shape a
// tool needs, so [completion.NewUseCaseTool] takes it unchanged and passes the acting subject through on
// every call. The tools are therefore built once, at start-up, and shared by every window and every user.
//
// The rules this file follows are worth stating explicitly, because they are what keeps an assistant safe:
//
//  1. A tool goes through a use case, never through a repository. The use case audits the acting subject, so
//     the assistant can only ever do what the person operating it could do by hand. This is not a
//     convention - it is the entire authorization model.
//  2. A tool that changes something is marked with AsMutating. Not described as writing in prose: marked. A
//     sentence in the system prompt is a request the model may ignore; the mark is a gate it cannot pass.
//  3. A listing goes through NewSeqTool, which bounds the result and tells the model when it was cut short.
//     Handing a model an unbounded list is how a context window gets exhausted.
//  4. Names are lower_snake_case and descriptions are written for someone who has never seen the
//     application. Both are validated at construction, so a mistake is a panic at start-up rather than a
//     confused model at runtime.
//  5. The domain types are handed to the model directly, carrying their own `desc` tags. A DTO is written
//     when the model needs something the aggregate does not have - not to rename fields.
//  6. WithResultDoc is used where the shape of the answer is not obvious from its field names. It costs
//     tokens on every request, so it is spent deliberately: the listing wraps its entries in a truncation
//     flag the model has to react to, while lend_book returns a sentence and a number that speak for
//     themselves.
func Tools(uc library.UseCases) []completion.Tool {
	list := completion.NewSeqTool("list_books",
		"Listet den Bestand der Bibliothek mit Titel, Autor, Gesamtzahl der Exemplare und den aktuellen Ausleihern. "+
			"Nutze die Filter, um die Antwort klein zu halten, statt die ganze Liste zu holen und selbst zu suchen.",
		uc.FindAllBooks).
		WithResultDoc()

	lend := completion.NewUseCaseTool("lend_book",
		"Leiht ein Exemplar eines Buches an eine Person aus. Nenne vorher Titel und Person, damit klar ist, was passiert.",
		uc.LendBook).
		AsMutating("gibt ein Exemplar heraus und trägt die Person als Ausleiher ein")

	ret := completion.NewUseCaseTool("return_book",
		"Nimmt ein ausgeliehenes Exemplar von einer Person zurück.",
		uc.ReturnBook).
		AsMutating("nimmt ein Exemplar zurück und entfernt die Person aus der Ausleihliste")

	return []completion.Tool{list, lend, ret}
}

// SystemPrompt is the domain half of what the model is told.
//
// It carries no requirement index and no list of decisions. Both come from the framework: aispeclink.Index
// renders the catalogue, and uicompletion.WindowContext renders where the user stands. Writing either of
// them here would be a copy of something already recorded, and the copy is the one that rots.
//
// What is deliberately absent is any instruction about asking before writing. That is enforced structurally
// by ConfirmMutations, and repeating it here would suggest it were the prompt's job.
const SystemPrompt = `Du hilfst beim Betrieb einer kleinen Bibliothek.

Arbeitsweise:
- Rate nicht. Alles Fachliche steht hinter Werkzeugen; wenn du etwas nicht weißt, hole es dir.
- Antworte knapp und in ganzen Sätzen. Nenne Bücher mit Titel und Autor, nicht mit ihrer Kennung.
- Wenn ein Werkzeug meldet, dass die Liste gekürzt wurde, sage das dazu und grenze die Suche ein, statt so zu tun, als wäre sie vollständig.
- Ein Berechtigungsfehler ist ein erwartetes Ergebnis, keine Störung. Sage dann, dass dem Nutzer die Berechtigung fehlt.

Fragt jemand, WARUM sich das System so verhält oder warum es etwas ablehnt, lies die zugehörige Anforderung
mit read_requirement und antworte daraus. Erfinde keine Begründung — eine erfundene Regel klingt wie das
System, das über sich selbst spricht, und ist schlimmer als keine Antwort. Bei Entscheidungen nenne auch,
was sie kostet, wenn jemand sie in Frage stellt.

Achte auf den Zustand einer Anforderung: „planned" heißt, dass etwas bewusst noch nicht umgesetzt ist. Das
ist etwas anderes als „gibt es nicht", und der Unterschied ist für den Fragenden der ganze Punkt.

Für Orientierungsfragen („was kann das hier eigentlich") nimm read_capabilities.`

Die Verdrahtung und der Einstiegspunkt:

// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

// Package cfglibrary wires the library context into a running application.
//
// It is the layer where storage, screens and domain meet, and the only one that is allowed to. The context
// itself knows only its repository interface, so swapping the store never touches a use case, and it knows
// nothing about its own screens.
package cfglibrary

import (
	"fmt"

	"go.wdy.de/nago/application"
	"go.wdy.de/nago/application/permission"
	"go.wdy.de/nago/application/role"
	"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library"
	uilibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/ui"
	"go.wdy.de/nago/pkg/data/json"
	"go.wdy.de/nago/presentation/core"
)

// RoleLibrarian is what an operator assigns to give somebody the library.
const RoleLibrarian role.ID = "tutorial.library.librarian"

// Module is what the rest of the application depends on.
type Module struct {
	UseCases library.UseCases
	Pages    uilibrary.Pages
}

// Enable builds the library context, seeds it, ships its role and registers its screens.
func Enable(cfg *application.Configurator) (Module, error) {
	store, err := cfg.EntityStore("tutorial.library.book")
	if err != nil {
		return Module{}, fmt.Errorf("cannot open book store: %w", err)
	}

	repo := library.BookRepository(json.NewSloppyJSONRepository[library.Book, library.BookID](store))
	if err := library.Seed(repo); err != nil {
		return Module{}, fmt.Errorf("cannot seed the shelf: %w", err)
	}

	uc := library.NewUseCases(repo)

	if err := declareLibrarianRole(cfg); err != nil {
		return Module{}, err
	}

	pages := uilibrary.Pages{Books: "."}

	// The purpose is registered with the route rather than with a menu entry, because the registration is the
	// only declaration every screen has: not every screen appears in a menu, and menus are rearranged freely.
	// uicompletion.WindowContext reads it to tell the model where the user is standing.
	cfg.RootViewWithDecoration(pages.Books, func(wnd core.Window) core.View {
		return uilibrary.PageBooks(wnd, uc)
	}, application.Purpose(
		"Den Bestand der Bibliothek durchsehen: welche Titel es gibt, wie viele Exemplare frei sind und wer welches ausgeliehen hat."))

	return Module{UseCases: uc, Pages: pages}, nil
}

// declareLibrarianRole ships the application's own authorization as one assignable role.
//
// Two things are worth noticing here.
//
// First, the assistant permissions are deliberately NOT in this list. cfgai declares its own system role
// (cfgai.RoleAssistantUser) holding exactly the three framework permissions the chat needs, and assigning
// that role is what makes the button appear. Keeping them apart means an operator can decide per user whether
// they get the assistant, without touching what the user may do in the application - and the assistant,
// bounded by the acting subject, still cannot exceed the library permissions granted here.
//
// Second, this is a system role, so it cannot be deleted and its permission set cannot be edited away in the
// admin UI. Both would be undone on the next start anyway; refusing outright is the honest version.
func declareLibrarianRole(cfg *application.Configurator) error {
	return cfg.DeclareSystemRole(role.Role{
		ID:          RoleLibrarian,
		Name:        "Bibliothekar",
		Description: "Darf den Bestand einsehen sowie Exemplare ausleihen und zurücknehmen.",
	}, LibrarianPermissions()...)
}

// LibrarianPermissions is spelled out as its own function so a test can check it without reaching into the
// declaration.
func LibrarianPermissions() []permission.ID {
	return []permission.ID{
		library.PermFindAllBooks,
		library.PermLendBook,
		library.PermReturnBook,
	}
}
// Copyright (c) 2025 worldiety GmbH
//
// This file is part of the NAGO Low-Code Platform.
// Licensed under the terms specified in the LICENSE file.
//
// SPDX-License-Identifier: Custom-License

// Command ai-example puts an AI assistant on top of an ordinary nago application.
//
// The entry point lives under cmd/ and does what an entry point does: it bootstraps the framework, enables
// the contexts and decides how they are reached. Every piece of business meaning is behind app/library.
package main

import (
	"time"

	"github.com/worldiety/option"
	"go.wdy.de/nago/application"
	cfgai "go.wdy.de/nago/application/ai/cfg"
	uicompletion "go.wdy.de/nago/application/ai/completion/ui"
	_ "go.wdy.de/nago/application/ai/provider/anthropic"
	_ "go.wdy.de/nago/application/ai/provider/mistralai"
	_ "go.wdy.de/nago/application/ai/provider/openai"
	aispeclink "go.wdy.de/nago/application/speclink/ai"
	cfgspeclink "go.wdy.de/nago/application/speclink/cfg"
	ailibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/ai"
	cfglibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/cfg"
	"go.wdy.de/nago/presentation/core"
	"go.wdy.de/nago/web/vuejs"
)

func main() {
	application.Configure(func(cfg *application.Configurator) {
		cfg.SetApplicationID("de.worldiety.tutorial_113")
		cfg.Serve(vuejs.Dist())

		option.MustZero(cfg.StandardSystems())
		option.Must(option.Must(cfg.UserManagement()).UseCases.EnableBootstrapAdmin(time.Now().Add(time.Hour), "%6UbRsCuM8N$auy"))

		// The context wires itself: store, use cases, its role and its screens. Nothing about the library
		// leaks into this file.
		lib := option.Must(cfglibrary.Enable(cfg))

		modAI := option.Must(cfgai.Enable(cfg))

		// The requirement catalogue of this binary, with ready-made tools on top. Nothing about the library
		// is repeated here: spec.Declare filled the catalogue during package initialisation, and the tools
		// read it through use cases that audit the acting subject like every other one.
		specMod := option.Must(cfgspeclink.Enable(cfg))

		// Build the tools once. They receive the acting subject per call, so there is nothing per-user about
		// them and nothing to rebuild per turn.
		tools := append(ailibrary.Tools(lib.UseCases), aispeclink.Tools(specMod.UseCases)...)

		scaffold := cfg.NewScaffold().
			Login(true).
			MenuEntry().Title("Bestand").Forward(lib.Pages.Books).Private().
			Decorator()

		// The assistant hangs on the decorator rather than on a page, so it is genuinely on every screen -
		// including the administration pages the framework brings along. When it cannot run (no provider, no
		// model, hidden by the operator, missing role) Decorate returns the view untouched and logs why.
		cfg.SetDecorator(func(wnd core.Window, view core.View) core.View {
			return modAI.Assistant.Decorate(wnd, scaffold(wnd, view), cfgai.AssistantOptions{
				Title: "Bibliotheks-Assistent",
				Label: "Assistent",
				// One tag for the whole application: the assistant follows the user from screen to screen, so
				// a conversation started on one page and continued on another is one conversation.
				Tags:             []string{"tutorial113:assistant"},
				History:          true,
				AskUser:          true,
				ConfirmMutations: true,
				MaxTurns:         32,
				Agents: []uicompletion.Agent{{
					ID:    "librarian",
					Name:  "Bibliotheks-Assistent",
					Tools: tools,
					// Rebuilt on every question, because both derived halves move: the requirement index grows
					// with the project, and the situational half changes with every navigation. Only the
					// domain half is a constant, and it is the only one written by hand.
					SystemPromptFunc: func() string {
						return ailibrary.SystemPrompt + "\n\n" +
							aispeclink.Index(wnd.Subject(), specMod.UseCases) + "\n" +
							uicompletion.WindowContext(wnd)
					},
				}},
			})
		})
	}).Run()
}