-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial Part 1
You run a maritime shipping company. You have ships, crews, voyages, cargo — and a handful of REST APIs that hold all that data. What you don't have is an AI agent that can actually uses them.
That's what we're going to build. Step-by-step, you'll go from zero to an operational agent capability: listing ships, planning voyages, resolving crew by name, and assembling a complete fleet manifest — all described in a single YAML file that Naftiko turns into live MCP tools, REST endpoints, and agent skills.
No code. Just a spec. Let's go.
Prerequisites: Make sure you can run the Naftiko Engine. See the installation instructions. All capability files for this tutorial live in
src/main/resources/tutorial/.
step-1-shipyard-first-capability.yml
The Maritime Registry at registry.shipyard.dev has a REST endpoint: GET /ships. It returns a list of ships. We want an agent to be able to call it. That's it — the absolute minimum.
A capability needs two things: something to consume (the API) and something to expose (the tool).
naftiko: "1.0.0-alpha1"
capability:
consumes:
- namespace: registry
type: http
baseUri: "https://registry.shipyard.dev/api/v1"
resources:
- name: ships
path: "/ships"
operations:
- name: list-ships
method: GET
exposes:
- type: mcp
port: 3001
namespace: shipyard-tools
description: "Shipyard MCP tools for fleet management"
tools:
- name: list-ships
description: "List ships in the shipyard"
call: registry.list-ships
outputParameters:
- type: array
mapping: "$."
items:
type: objectRun the engine and connect an MCP client to localhost:3001. You can use the MCP Inspector to browse tools and call them interactively — it's the fastest way to test during development. Call list-ships. You get:
[
{ "imoNumber": "IMO-9321483", "vesselName": "Northern Star", "vesselType": "cargo", "flagCode": "NO", "operationalStatus": "active" },
{ "imoNumber": "IMO-9456781", "vesselName": "Pacific Dawn", "vesselType": "tanker", "flagCode": "SG", "operationalStatus": "maintenance" }
]That's your first tool. consumes declares where the data lives, exposes declares what the agent sees. call: registry.list-ships is the wire between the two. The outputParameters with mapping rename fields from the API's snake_case (imo_number) to camelCase (imoNumber) — the agent never sees the raw API shape.
What you learned: consumes, exposes, type: mcp, call, outputParameters, mapping.
step-2-shipyard-input-parameters.yml
An agent that can only list all ships isn't very useful. We need two things: a way to filter the list (by status), and a way to look up a specific ship (by IMO number).
tools:
- name: get-ship
description: "Retrieve a ship's details by IMO number"
inputParameters:
- name: imo
type: string
required: true
description: "IMO number of the ship"
call: registry.get-ship
with:
imo_number: "{{imo}}"The with keyword is the bridge: the agent says imo, the consumed API expects imo_number — with maps one to the other. On the consumes side, status becomes a query parameter (GET /ships?status=active) and imo_number fills a path template (GET /ships/IMO-9321483).
Now the agent can ask: "Show me only the active ships" and "Tell me about the Northern Star."
{ "imo": "IMO-9321483", "name": "Northern Star", "type": "cargo", "flag": "NO", "status": "active" }What you learned: inputParameters, with, required vs optional, path vs query parameters.
step-3-shipyard-auth-and-binds.yml
So far, we've been hitting the registry's public endpoints — they return 5 basic fields per ship. But the registry has much more: specs, dimensions, tonnage, crew assignments, certifications. That data sits behind a bearer token.
binds:
- namespace: registry-env
location: "file:///./shared/secrets.yaml"
keys:
REGISTRY_TOKEN: "registry-bearer-token"
REGISTRY_VERSION: "registry-api-version"
capability:
consumes:
- namespace: registry
type: http
baseUri: "https://registry.shipyard.dev/api/v1"
authentication:
type: bearer
token: "{{REGISTRY_TOKEN}}"
inputParameters:
- name: Registry-Version
in: header
value: "{{REGISTRY_VERSION}}"binds loads secrets from a file (in production, from a vault or env vars). authentication adds the token to every request. The consumes-level inputParameters inject a version header on all operations automatically.
The tools themselves don't change — same list-ships, same get-ship, same signatures. But behind the scenes, the registry now returns 30+ fields instead of 5. We'll deal with that next.
Create a secrets.yaml next to your capability:
REGISTRY_TOKEN: "sk-registry-XXXXXXXXXXXX"
REGISTRY_VERSION: "2024-01-01"What you learned: binds, authentication, consumes-level inputParameters, secret injection.
step-4-shipyard-output-shaping.yml
Now get-ship returns everything: year built, gross tonnage, length overall, beam, draft, classification society, certifications, crew assignments… An agent asking "tell me about Northern Star" doesn't need 30 fields. It needs a ship card.
- name: get-ship
call: registry.get-ship
with:
imo_number: shipyard-tools.imo
outputParameters:
- type: object
properties:
imo:
type: string
mapping: "$.imo_number"
name:
type: string
mapping: "$.vessel_name"
type:
type: string
mapping: "$.vessel_type"
flag:
type: string
mapping: "$.flag_code"
status:
type: string
mapping: "$.operational_status"
specs:
type: object
properties:
yearBuilt:
type: number
mapping: "$.year_built"
tonnage:
type: number
mapping: "$.gross_tonnage"
length:
type: number
mapping: "$.dimensions.length_overall"The specs nested object is the key: mapping: $.dimensions.length_overall reaches deep into the API response and pulls out just the length. Auth gave us everything, shaping gives the agent only what it needs.
{
"imo": "IMO-9321483",
"name": "Northern Star",
"type": "cargo",
"flag": "NO",
"status": "active",
"specs": { "yearBuilt": 2015, "tonnage": 42000, "length": 229 }
}What you learned: Nested outputParameters, deep JSONPath mapping, the rightsize pattern.
step-5-shipyard-multi-source.yml — Consumes: shared/step5-registry-consumes.yaml, shared/legacy-consumes.yaml
📥 step-5-shipyard-multi-source.yml 📥 step5-registry-consumes.yaml 📥 legacy-consumes.yaml
Data rarely lives in one place. The Shipyard's modern registry is clean, but there's also a legacy Dockyard — an older system with records for vessels that were never migrated. Different API, different auth (API key instead of bearer), different field names.
We want the agent to search both. But Naftiko can't merge two list results into one tool today — so each source gets its own tool. The agent calls both and merges on its side (which agents are great at).
This step also introduces consumes import: the registry definition is extracted to shared/step5-registry-consumes.yaml so it can be reused across capabilities without copy-paste.
capability:
consumes:
- import: registry
location: ./shared/step5-registry-consumes.yaml
- import: legacy
location: ./shared/legacy-consumes.yaml
exposes:
- type: mcp
address: "0.0.0.0" # Needed in Docker context. Permits to access the MCP with localhost from outside the container.
port: 3001
namespace: shipyard-tools
tools:
- name: list-legacy-vessels
description: "List vessels from the legacy dockyard"
call: legacy.list-vessels
outputParameters:
- type: array
mapping: "$."
items:
type: objectNew tool: list-legacy-vessels — same pattern as list-ships, different source:
[
{ "vesselCode": "LEGACY-4012", "name": "Old Faithful", "type": "cargo", "flag": "GB", "status": "active" },
{ "vesselCode": "LEGACY-2087", "name": "Iron Maiden", "type": "bulk_carrier", "flag": "PA", "status": "laid_up" }
]What you learned: Multiple consumes, import + location, different auth types, API normalization.
step-6-shipyard-write-operations.yml — Consumes: shared/step6-registry-consumes.yaml, shared/legacy-consumes.yaml
📥 step-6-shipyard-write-operations.yml 📥 step6-registry-consumes.yaml 📥 legacy-consumes.yaml
Until now, every tool was read-only. List, get, inspect. But Captain Erik Lindström wants to plan a voyage — Oslo to Singapore, aboard the Northern Star, with his crew and cargo. The agent needs to act.
create-voyage is the first write tool — a POST to the registry with 7 input parameters, including arrays for crew and cargo IDs:
- name: create-voyage
description: "Plan a new voyage with ship, crew, route, and dates"
inputParameters:
- name: shipImo
type: string
required: true
- name: departurePort
type: string
required: true
- name: arrivalPort
type: string
required: true
- name: departureDate
type: string
required: true
- name: arrivalDate
type: string
required: true
- name: crewIds
type: array
required: true
description: "List of crew member IDs"
- name: cargoIds
type: array
required: false
description: "List of cargo item IDs"
call: registry.create-voyageThe response gets shaped too — flat fields like departurePort/arrivalPort become a clean route object:
{
"voyageId": "VOY-2026-042",
"shipImo": "IMO-9321483",
"route": { "from": "Oslo", "to": "Singapore" },
"dates": { "departure": "2026-04-10", "arrival": "2026-05-02" },
"crewIds": ["CREW-001", "CREW-003"],
"cargoIds": ["CARGO-2024-0451"],
"status": "planned"
}Note: All scalar fields in the response (
voyageId,shipImo,route,dates,status) are dynamically echoed from your request by the mock server. Array fields (crewIds,cargoIds) are an exception — mock servers don't support dynamic array templating, so they return fixed example values instead.
The agent went from observer to operator.
What you learned: POST operations, body template, array-type inputs, write tools.
step-7-shipyard-orchestrated-lookup.yml — Consumes: shared/step7-registry-consumes.yml, shared/legacy-consumes.yaml
📥 step-7-shipyard-orchestrated-lookup.yml 📥 step7-registry-consumes.yaml 📥 legacy-consumes.yaml
Captain Erik is planning Oslo → Singapore. He insists on his cook: "No Aiko, no departure." The agent calls get-ship — but gets assignedCrew: ["CREW-001", "CREW-003"]. Raw IDs. Useless. Who is CREW-003? The captain needs names.
Instead of forcing the agent to call a second API and cross-reference, we enrich server-side. This is orchestrated mode: a tool with steps instead of a simple call.
- name: get-ship-with-crew
description: "Get ship details with resolved crew names"
inputParameters:
- name: imo
type: string
required: true
steps:
- name: get-ship
type: call
call: registry.get-ship
with:
imo_number: shipyard-tools.imo
- name: list-crew
type: call
call: registry.list-crew
- name: resolve-crew
type: lookup
index: list-crew
match: crewId
lookupValue: "$.get-ship.assignedCrew"
outputParameters:
- "fullName"
- "role"
mappings:
- targetName: imo
value: "$.get-ship.imo_number"
- targetName: name
value: "$.get-ship.vessel_name"
- targetName: crew
value: "$.resolve-crew"Three steps: (1) fetch the ship, (2) fetch the crew roster, (3) lookup — a server-side JOIN that matches crewId against the ship's assignedCrew array and pulls out fullName and role. One tool call, zero agent round-trips.
{
"imo": "IMO-9321483",
"name": "Northern Star",
"type": "cargo",
"flag": "NO",
"status": "active",
"specs": { "yearBuilt": 2015, "tonnage": 42000, "length": 229 },
"crew": [
{ "fullName": "Erik Lindström", "role": "captain" },
{ "fullName": "Aiko Tanaka", "role": "cook" }
]
}Aiko is on board. The captain is happy.
What you learned: steps, type: lookup, index/match/lookupValue, mappings.
Over 7 steps, your single YAML capability grew from a 15-line wrapper around GET /ships into a multi-source, write-capable, orchestrated agent platform:
5 MCP tools — list-ships, get-ship, list-legacy-vessels, create-voyage, get-ship-with-crew
2 consumed APIs — the Maritime Registry (bearer auth, 5 operations) and the Legacy Dockyard (API key, 1 operation)
All from one spec. No code. Welcome to Spec-Driven Integration.
Ready to expose your tools as agent skills, add a REST front door, and assemble a full Fleet Manifest with multi-step orchestration?
Continue with the Tutorial - Part 2.