Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/filtered-directory-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offering-protocol/directory": patch
---

Support filtered Directory suggestions through POST while retaining Service-only keyword suggestions.
5 changes: 5 additions & 0 deletions .changeset/mixed-directory-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offering-protocol/directory": patch
---

Add mixed Service and Collection Directory search, continuation support, and name suggestions. Preserve Service-only discovery methods and expose unknown future result types without discarding their data.
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ Official TypeScript software development kits for the
Services and navigating their Offerings.

ODP separates Service discovery from catalog discovery. An Agent searches the canonical directory
for candidate Services, inspects each Service's live ODP document, and then navigates or searches
for candidate Services or indexed Collections, inspects the owning Service's live ODP document, and then navigates or searches
that Service's Collections and Offerings. Full Offering details can describe structured attributes,
price previews, and executable Actions without forcing every industry into one product schema.

```text
Agent Canonical Directory Service
│ │ │
├── Search Services ─────────────────▶│ │
│◀── Cached Service metadata ─────────┤ │
├── Search Directory ────────────────▶│ │
│◀── Service / Collection metadata ───┤ │
│ │ │
├── Inspect /.well-known/odp ──────────────────────────────────────────────▶│
│◀── Operations and protocol capabilities ──────────────────────────────────┤
Expand All @@ -40,7 +40,7 @@ Choose the role you are implementing:
| ------------------------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| An Agent, command-line tool, or automation | [`@offering-protocol/agent`](./packages/agent/README.md) | Directory-to-Service search, catalog navigation, enrichment, and Action discovery |
| A Service with an ODP catalog | [`@offering-protocol/service`](./packages/service/README.md) | Service document, fixed routes, static catalogs, and storage-backed handlers |
| A canonical-directory integration | [`@offering-protocol/directory`](./packages/directory/README.md) | Production or sandbox Service search with bounded lazy pagination |
| A canonical-directory integration | [`@offering-protocol/directory`](./packages/directory/README.md) | Mixed Service/Collection search, suggestions, and Service-only discovery |
| An ODP implementation or validation tool | [`@offering-protocol/core`](./packages/core/README.md) | Protocol models, bundled schemas, validation, identity, references, and pagination |

All packages are ESM-first, support Node.js 22 or newer, and publish under the
Expand All @@ -63,8 +63,14 @@ Use `npm install` or `yarn add` if those are the package managers in your applic

## Agent Workflow

For general Directory discovery, use [`directory.search()`](./packages/directory/README.md#search-the-directory)
to receive typed Service and Collection results, and `directory.suggest()` to obtain matching names.
The Directory indexes submitted Collections, not every Offering in a Service's catalog. Its mixed
search returns at most 100 results and currently has no continuation; refine queries to narrow results.

`createOdpAgent` searches the canonical directory and then searches the live catalogs of matching
Services. Directory results never pretend to contain complete Service catalogs.
Services. This orchestration uses `searchServices()`, the Service-only API, and does not interpret
mixed results as Services. Directory results never pretend to contain complete Service catalogs.

```ts
import { createOdpAgent } from "@offering-protocol/agent";
Expand Down
17 changes: 12 additions & 5 deletions examples/odp-agent-discovery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ This example performs the two-stage discovery flow against any reachable ODP Ser
`.env`.

The directory is explicitly a mock. `src/mock-directory.ts` probes the configured Service URLs,
builds cached directory entries only for reachable Services, and implements the sandbox Service-search
request in memory. It does not contact a deployed directory or pretend to be its implementation.
builds cached directory entries for reachable Services, and samples at most two Collections from
the first page when both Collection listing and detail retrieval are advertised without required
authentication. It implements unfiltered mixed and
Service-only search requests in memory. This bounded sampling seeds example data only: the deployed
Directory indexes explicitly submitted Collections, rather than crawling each Service. The mock
does not contact a deployed directory or implement its filtering, ranking, or suggestion query.

Enter the example directory, copy the configuration template, and run the agent after starting any
of the example Services:
Expand All @@ -17,6 +21,9 @@ pnpm build
pnpm start
```

Unreachable URLs are skipped. For each reachable Service, the output narrates and prints the mock
directory entry, validated ODP Service document, first terse Offering page, and full details for the
first Offering.
Unreachable URLs are skipped. The example calls `directory.search()` and branches on each result's
`type`. For a Service result, it prints the mock entry, validated ODP Service document, first terse
Offering page, and full details for the first Offering. For a Collection result, it prints the entry,
inspects the owning Service, and fetches the live Collection using that Service and Collection ID.
Unknown result types are reported without contacting their contents. The marketplace example
provides Collections, so run it to exercise both known result types.
18 changes: 15 additions & 3 deletions examples/odp-agent-discovery/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ for (const unavailable of mock.unavailable)

const directory = createDirectoryClient({ environment: "sandbox", transport: mock.transport });
let discovered = 0;
for await (const service of directory.searchServices().items) {
for await (const result of directory.search().items) {
if (result.type === "unknown") {
print("Unrecognized directory result", result);
continue;
}
const service = result.service;
discovered += 1;
const serviceUrl = mock.serviceUrlFor(service.service_origin);
const client = createOdpServiceClient({
Expand All @@ -36,12 +41,19 @@ for await (const service of directory.searchServices().items) {
initialPageSize: 2
});

heading(`SERVICE ${discovered}: ${service.name}`);
print("Mock directory entry", service);
heading(
`${result.type.toUpperCase()} ${discovered}: ${result.type === "collection" ? result.collection.name : service.name}`
);
print("Mock directory entry", result);

const inspection = await client.inspect();
print("ODP Service document", inspection.document);

if (result.type === "collection") {
print("Full Collection response", await client.getCollection(result.collection.id));
continue;
}

const page = await client.listOfferings().pages[Symbol.asyncIterator]().next();
if (page.done) {
process.stdout.write("Offering list is empty.\n");
Expand Down
61 changes: 50 additions & 11 deletions examples/odp-agent-discovery/src/mock-directory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { randomUUID } from "node:crypto";

import { createOdpServiceClient } from "@offering-protocol/agent";
import type { DirectoryService, DirectoryTransport } from "@offering-protocol/directory";
import type {
DirectoryIndexedService,
DirectoryResult,
DirectoryTransport
} from "@offering-protocol/directory";

export interface MockDirectory {
transport: DirectoryTransport;
Expand All @@ -8,21 +14,24 @@ export interface MockDirectory {
}

export async function createMockDirectory(serviceUrls: string[]): Promise<MockDirectory> {
const services: DirectoryService[] = [];
const services: DirectoryIndexedService[] = [];
const items: DirectoryResult[] = [];
const localUrls = new Map<string, string>();
const unavailable: Array<{ serviceUrl: string; message: string }> = [];

for (const [index, serviceUrl] of serviceUrls.entries()) {
try {
const inspection = await createOdpServiceClient({
const client = createOdpServiceClient({
serviceUrl,
allowLocalNetwork: true,
cachePartition: "mock-directory",
signal: AbortSignal.timeout(2_000)
}).inspect();
});
const inspection = await client.inspect();
const document = inspection.document;
const serviceOrigin = `https://service-${index + 1}.mock-directory.example`;
services.push({
const service: DirectoryIndexedService = {
service_id: randomUUID(),
service_origin: serviceOrigin,
name: document.name,
description: document.description,
Expand All @@ -32,8 +41,32 @@ export async function createMockDirectory(serviceUrls: string[]): Promise<MockDi
operations: [...document.operations],
...(document.protocols === undefined ? {} : { protocols: document.protocols }),
indexed_at: "2026-08-02T00:00:00Z"
});
};
services.push(service);
items.push({ type: "service", service, indexed_at: service.indexed_at });
localUrls.set(serviceOrigin, serviceUrl);
if (
["list-collections", "get-collection"].every((name) =>
document.operations.some(
(operation) => operation.name === name && operation.authentication !== "required"
)
)
) {
for await (const collection of client.listCollections({ maxPages: 1, maxItems: 2 }).items) {
items.push({
type: "collection",
service,
indexed_at: service.indexed_at,
collection: {
id: collection.id,
name: collection.name,
...(collection.description === undefined
? {}
: { description: collection.description })
}
});
}
}
} catch (error) {
unavailable.push({
serviceUrl,
Expand All @@ -50,14 +83,20 @@ export async function createMockDirectory(serviceUrls: string[]): Promise<MockDi
return Promise.resolve(
new Response("Mock directory received the wrong origin", { status: 500 })
);
if (url.pathname !== "/v1/services/search" || init?.method !== "POST")
if (
!["/v1/services/search", "/v1/directory/search"].includes(url.pathname) ||
init?.method !== "POST"
)
return Promise.resolve(
new Response("Mock directory supports Service search only", { status: 404 })
new Response("Mock directory supports search requests only", { status: 404 })
);
return Promise.resolve(
new Response(JSON.stringify({ items: services }), {
headers: { "content-type": "application/json" }
})
new Response(
JSON.stringify({ items: url.pathname === "/v1/services/search" ? services : items }),
{
headers: { "content-type": "application/json" }
}
)
);
},
serviceUrlFor(serviceOrigin) {
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Agent-oriented composition across directory discovery and per-Service catalog discovery.

For mixed Directory discovery, use `search()` from `@offering-protocol/directory`. A Collection
result supplies its owning `service.service_origin` and `collection.id`; use those with
`createOdpServiceClient({ serviceUrl: result.service.service_origin })`, inspect the Service, and
call `getCollection(result.collection.id)` to obtain live details. A Directory entry is cached
metadata, not the authoritative Collection. `createOdpAgent`'s cross-Service Offering search uses
the Service-only `searchServices()` method; mixed search does not change that orchestration.

## Install

```sh
Expand Down
85 changes: 81 additions & 4 deletions packages/directory/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `@offering-protocol/directory`

The official client for canonical Offering Discovery Protocol Service discovery.
The official client for discovering Services and indexed Collections in the canonical directory.

## Install

Expand All @@ -16,9 +16,64 @@ The package has two environments and no configurable base URL:

A fetch-compatible `transport` can be injected for testing without changing the selected origin.

## Search for Services
## Search the Directory

Directory search covers cached Service metadata, not Service catalogs. Filter values within one
`search()` returns Services and explicitly indexed Collections. It searches cached names,
descriptions, and Service keywords; it does not crawl catalogs or search individual Offerings.
Omit `types` to include both result types, or pass `["service"]` or `["collection"]`.

```ts
import { createDirectoryClient } from "@offering-protocol/directory";

const directory = createDirectoryClient();
for await (const result of directory.search({ query: "weather", limit: 25 }).items) {
switch (result.type) {
case "service":
useService(result.service.service_origin);
break;
case "collection":
useCollection(result.service.service_origin, result.collection.id);
break;
case "unknown":
reportUnsupportedType(result.resource_type, result.raw);
break;
}
}
```

The example's `useService`, `useCollection`, and `reportUnsupportedType` functions represent your
application's handling of a result. A Collection ID is scoped to its owning Service: two Services
can both have a Collection named `weather`. Preserve the origin and ID together. Inspect the
Service's live ODP document before fetching authoritative Collection details through
`createOdpServiceClient` from `@offering-protocol/agent`.

Each known result carries `service` metadata and `indexed_at`. For a Collection, the outer timestamp
describes its indexed metadata; `service.indexed_at` describes its parent. A Service result can have
`available_through`, a platform reference with `service_id`, `service_origin`, and optional `name`.
This describes availability, not brand ownership. A Collection's owning `service` provides its
attribution.

Mixed filters use the same `filters` structure shown below and apply to the owning Service.
Whitespace-separated query terms are alternatives, matched as case-insensitive substrings.
Facets count all matching targets: a Service and two matching Collections count as three, even if
the response limit excludes some of them.

`items` and `pages` are independent lazy traversals beginning with `POST /v1/directory/search`.
The server returns at most 100 items (also its default) and currently provides no continuation.
**An absent `next` does not mean every matching target was returned.** Narrow the query or filters
when necessary. The client supports optional same-origin continuation links when supplied, and
`continueSearch(next)` resumes one with GET. It does not invent cursors or additional requests.
`maxItems`, `maxPages`, and `signal` work as with Service-only search.

Known result types are validated; malformed entries appear in the page's `issues` with their
original indexes and are omitted from `items`. An unfamiliar type is instead preserved as
`{ type: "unknown", resource_type, raw }`. Do not treat its unvalidated `raw` content as a Service
or automatically follow URLs inside it. Additional fields on known results are tolerated. Nested
Service metadata uses the validation policy documented below.

## Search for Services only

`searchServices()` covers cached Service metadata, not Collections or Service catalogs. Filter values within one
category use OR semantics; different categories combine with AND semantics. The initial page can
include facets for keywords, enrollment protocols, payment protocols, individual protocol payment
options, trust protocols, and ODP operation descriptors.
Expand Down Expand Up @@ -78,7 +133,29 @@ Service undiscoverable.

## Suggestions

`suggestServices` returns bounded lexical suggestions for a prefix. Natural-language interpretation
`suggest()` finds matching index rows across Service and Collection names, descriptions, and
Service keywords, then returns the **names of matching targets**. Matching is case-insensitive
substring search, despite the input parameter being named `prefix`. For example, `we` can match
`weather` in a description and return the Collection name `AccuWeather`.

```ts
const names = await directory.suggest({
prefix: "we",
limit: 10,
filters: { payments: [{ name: "mpp", options: ["inflow"] }] }
});
// Use a selected name as the query for directory.search().
```

The endpoint is `POST /v1/directory/suggestions`. Optional `filters` use the same structure as search,
including keywords, AEP, ODP operations, payments, and trust. Collection filters apply to their owning
Service. Suggestions are deduplicated search strings, not
resource identifiers. They do not tell you which target type supplied each name. The server ranks
names by the total number of matching index rows, then alphabetically, and returns at most 25.
Collection suggestions do not require permission to display that Collection's landing-page card.

`suggestServices()` calls `GET /v1/services/suggestions` and returns matching **Service keywords**
beginning with the prefix. It does not accept filters. Pair it with `searchServices()` for Service-only workflows. Natural-language interpretation
is not required by the directory contract. The client de-duplicates the response and returns at most
25 suggestions whatever the server sends.

Expand Down
Loading
Loading