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
13 changes: 13 additions & 0 deletions examples/ligare/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Duffel Test API key (https://app.duffel.com → test mode). Required for live search/price/book.
DUFFEL_API_KEY=duffel_test_your_key_here

# Port the Fastify server listens on.
PORT=3000

# Public base URL the generated OpenAPI spec advertises (the GPT's Action server).
# Local dev can leave this unset (defaults to http://localhost:$PORT).
PUBLIC_BASE_URL=https://ligare.telivity.app

# Published GPT link. After you publish the "Telivity Ligare" GPT, paste its URL
# here so the landing page "Try Live Demo" button points at it.
GPT_URL=
4 changes: 4 additions & 0 deletions examples/ligare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.env
leads.jsonl
76 changes: 76 additions & 0 deletions examples/ligare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Ligare — Connect Travel Inventory to ChatGPT

Ligare is a demo of [OTAIP](../../README.md): it takes a real supplier adapter
(**Duffel Test**) and exposes it as **ChatGPT Custom GPT Actions**, so you can
search and book flights from inside a chat.

> **Sandbox only.** Everything runs on Duffel Test. Flights and bookings are
> simulated — not real tickets, no real money.

It ships **two demos from one codebase**:

- **`pnpm ligare`** — a single-command, self-narrating developer demo: wraps
Duffel as a `ConnectAdapter`, generates the ChatGPT/MCP tools from it, runs a
live Duffel Test search, and serves the OpenAPI spec a GPT can import.
- **The hosted "Telivity Ligare" GPT** — the same backend, published once to the
ChatGPT store, so anyone can search/book sandbox flights with zero signup.

## How it works

```
ChatGPT (Telivity Ligare GPT) ──HTTPS──► Fastify server
(runs on the user's own model) ├─ GET /openapi.json (the GPT's Action, generated)
├─ POST /flights/search ─┐
├─ POST /flights/price ├─► DuffelConnectAdapter ─► DuffelAdapter ─► Duffel TEST
├─ POST /bookings ┘
├─ GET /bookings/:id
├─ GET /health
├─ GET / (Telivity landing page)
└─ POST /leads (email capture)
```

The published GPT runs on the user's own ChatGPT model, so this backend needs
**no OpenAI key** and incurs **no token cost** — it only serves the Duffel
Actions. The one piece of real code is [`src/duffel-connect-adapter.ts`](./src/duffel-connect-adapter.ts),
which bridges Duffel's low-level `DistributionAdapter` to OTAIP's high-level
`ConnectAdapter` so the spec and the routes share one source of truth.

## Quick start (developer demo)

```bash
cp examples/ligare/.env.example examples/ligare/.env
# edit .env → set DUFFEL_API_KEY=duffel_test_... (https://app.duffel.com, test mode)

pnpm install
pnpm -r build # build the @otaip/* workspace deps
pnpm ligare # narrated demo + live server
```

Without a `DUFFEL_API_KEY` the demo still runs — it prints the generated tools
and serves `/openapi.json`, and skips the live search.

## Publish the GPT (one-time)

OpenAI has no API to publish a GPT, so this is a one-time manual step:

1. Deploy this server so `https://ligare.telivity.app/openapi.json` is reachable.
2. In ChatGPT: **Create a GPT → Configure → Actions → Import from URL** →
`https://ligare.telivity.app/openapi.json`. Add the name, logo, and
instructions, then **Publish**.
3. Paste the published GPT URL into `GPT_URL` in `.env` so the landing page’s
**Try Live Demo** button points at it.

## Want your own inventory?

The greyed-out story: Ligare supports Sabre, Amadeus, Navitaire, Duffel and more
through OTAIP's adapters. Connecting *your* inventory uses *your* supplier
credentials — that’s the white-glove product, not this sandbox demo.

## Env

| Var | Purpose |
| --- | --- |
| `DUFFEL_API_KEY` | Duffel **Test** key. Required for live search/price/book. |
| `PORT` | Server port (default 3000). |
| `PUBLIC_BASE_URL` | Base URL advertised in the OpenAPI `servers` block. |
| `GPT_URL` | Published GPT link for the landing page button. |
29 changes: 29 additions & 0 deletions examples/ligare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@otaip/example-ligare",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Ligare — connect travel inventory to ChatGPT. OTAIP Duffel adapter exposed as ChatGPT Custom GPT Actions.",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"demo": "tsx src/demo.ts",
"build": "tsup src/server.ts --format esm --clean",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@otaip/core": "workspace:*",
"@otaip/adapter-duffel": "workspace:*",
"@otaip/connect": "workspace:*",
"fastify": "^5.3.3",
"@fastify/static": "^9.1.1",
"dotenv": "^17.4.0"
},
"devDependencies": {
"tsx": "^4.21.0",
"tsup": "^8.5.1"
},
"engines": {
"node": ">=24.14.1"
}
}
99 changes: 99 additions & 0 deletions examples/ligare/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ligare — Connect Travel Inventory to ChatGPT · Telivity</title>
<meta
name="description"
content="Ligare by Telivity — connect your travel inventory to ChatGPT. Search and book flights inside a chat, powered by OTAIP."
/>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 antialiased">
<!-- Sandbox disclosure -->
<div class="bg-amber-500/10 border-b border-amber-500/30 text-amber-200 text-center text-sm py-2 px-4">
Sandbox demo — flights &amp; bookings are simulated (Duffel Test), not real.
</div>

<main class="mx-auto max-w-3xl px-6 py-20 sm:py-28">
<p class="text-sm font-semibold tracking-widest text-cyan-400 uppercase">Ligare · by Telivity</p>

<h1 class="mt-5 text-4xl sm:text-6xl font-bold tracking-tight">
Connect travel inventory to <span class="text-cyan-400">ChatGPT</span>.
</h1>

<p class="mt-6 text-lg text-slate-300">
Ligare turns your supplier API into a ChatGPT-ready booking assistant — search and book
flights inside a chat. This live demo runs on Duffel&nbsp;Test, powered by
<span class="text-slate-100 font-medium">OTAIP</span>.
</p>

<div class="mt-10 flex flex-wrap gap-4">
<a
href="/go"
class="inline-flex items-center rounded-lg bg-cyan-500 px-6 py-3 font-semibold text-slate-950 hover:bg-cyan-400 transition"
>
Try Live Demo →
</a>
<a
href="/openapi.json"
class="inline-flex items-center rounded-lg border border-slate-700 px-6 py-3 font-semibold text-slate-200 hover:border-slate-500 transition"
>
View the OpenAPI spec
</a>
</div>

<!-- Lead capture -->
<section class="mt-20 rounded-2xl border border-slate-800 bg-slate-900/50 p-8">
<h2 class="text-2xl font-semibold">Want your own inventory connected?</h2>
<p class="mt-2 text-slate-400">
Bring your Sabre, Amadeus, Navitaire, Duffel or NDC connection — we do the integration,
branding, and deploy. Leave your email and we’ll reach out.
</p>
<form id="lead-form" class="mt-6 flex flex-col sm:flex-row gap-3">
<input
id="lead-email"
type="email"
required
placeholder="you@yourcompany.com"
class="flex-1 rounded-lg bg-slate-950 border border-slate-700 px-4 py-3 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none"
/>
<button
type="submit"
class="rounded-lg bg-slate-100 px-6 py-3 font-semibold text-slate-950 hover:bg-white transition"
>
Connect my inventory
</button>
</form>
<p id="lead-status" class="mt-3 text-sm text-cyan-400 hidden">Thanks — we’ll be in touch.</p>
</section>

<footer class="mt-20 text-sm text-slate-500">
Ligare — Powered by Telivity. Built on OTAIP.
</footer>
</main>

<script>
const form = document.getElementById('lead-form');
const status = document.getElementById('lead-status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('lead-email').value;
try {
const res = await fetch('/leads', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, note: 'landing' }),
});
if (res.ok) {
status.classList.remove('hidden');
form.reset();
}
} catch (_) {
/* no-op for the demo */
}
});
</script>
</body>
</html>
75 changes: 75 additions & 0 deletions examples/ligare/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Fastify app for Ligare: serves the Telivity landing page and the ChatGPT
* Action endpoints that the published GPT calls. The HTTP paths match the
* generated OpenAPI spec exactly (POST /flights/search, /flights/price,
* /bookings, GET /bookings/:id, /health).
*/

import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import type {
CreateBookingInput,
PassengerCount,
SearchFlightsInput,
} from '@otaip/connect';
import { DuffelConnectAdapter } from './duffel-connect-adapter.js';
import { buildOpenApiSpec } from './openapi.js';
import { recordLead } from './leads.js';

const here = dirname(fileURLToPath(import.meta.url));

export function createAdapter(): DuffelConnectAdapter {
return new DuffelConnectAdapter();
}

export function buildServer(adapter: DuffelConnectAdapter): FastifyInstance {
const app = Fastify({ logger: false });

// --- ChatGPT Action endpoints (must match the OpenAPI paths) ---
app.get('/openapi.json', async () => buildOpenApiSpec(adapter));
app.get('/health', async () => adapter.healthCheck());

app.post('/flights/search', async (req) =>
adapter.searchFlights(req.body as SearchFlightsInput),
);

app.post('/flights/price', async (req) => {
const { offerId, passengers } = req.body as {
offerId: string;
passengers: PassengerCount;
};
return adapter.priceItinerary(offerId, passengers);
});

app.post('/bookings', async (req) =>
adapter.createBooking(req.body as CreateBookingInput),
);

app.get('/bookings/:id', async (req) =>
adapter.getBookingStatus((req.params as { id: string }).id),
);

// --- Landing-page support ---
app.post('/leads', async (req, reply) => {
const { email, note } = (req.body ?? {}) as { email?: string; note?: string };
if (!email || email.trim().length === 0) {
reply.code(400);
return { ok: false, error: 'email required' };
}
await recordLead(email.trim(), note);
return { ok: true };
});

// "Try Live Demo" button → redirect to the published GPT (or home if unset).
app.get('/go', async (_req, reply) => {
const url = process.env['GPT_URL'];
return reply.redirect(url && url.trim().length > 0 ? url : '/');
});

// Static landing page (serves public/index.html at '/').
app.register(fastifyStatic, { root: join(here, '..', 'public') });

return app;
}
89 changes: 89 additions & 0 deletions examples/ligare/src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Single-command developer demo: `pnpm ligare`
*
* Shows, in ~10 seconds, how OTAIP turns a raw supplier adapter into live
* ChatGPT-ready tools and a running API:
* 1. Wrap Duffel as a ConnectAdapter
* 2. Generate the ChatGPT/MCP tools from it
* 3. Run a live Duffel Test search
* 4. Serve the OpenAPI spec a GPT can import
*/

import 'dotenv/config';
import { generateMcpTools } from '@otaip/connect';
import { buildServer, createAdapter } from './app.js';
import { buildOpenApiSpec, publicBaseUrl } from './openapi.js';

const PORT = Number(process.env['PORT'] ?? 3000);

function plusDays(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}

function hasDuffelKey(): boolean {
const key = process.env['DUFFEL_API_KEY']?.trim();
return !!key && key !== 'duffel_test_your_key_here';
}

async function main(): Promise<void> {
console.log('\n Ligare — connect travel inventory to ChatGPT (OTAIP demo)\n');
console.log(' Sandbox: Duffel Test. Flights and bookings are simulated, not real.\n');

// Step 1 — wrap the supplier
const adapter = createAdapter();
console.log(` 1. Wrapped Duffel as a ConnectAdapter (supplierId="${adapter.supplierId}")`);

// Step 2 — generate AI tools from the adapter
const tools = generateMcpTools(adapter);
const spec = buildOpenApiSpec(adapter);
const operationIds = Object.values(spec['paths'] as Record<string, Record<string, { operationId?: string }>>)
.flatMap((methods) => Object.values(methods).map((op) => op.operationId))
.filter((id): id is string => Boolean(id));
console.log(` 2. Generated ${tools.length} ChatGPT/MCP tools from the adapter:`);
console.log(` MCP tools: ${tools.map((t) => t.name).join(', ')}`);
console.log(` OpenAPI ops: ${operationIds.join(', ')}`);

// Step 3 — live search
if (!hasDuffelKey()) {
console.log('\n 3. Live search skipped — set DUFFEL_API_KEY (Duffel Test) in your .env to see real offers.\n');
} else {
const departureDate = plusDays(14);
console.log(`\n 3. Live Duffel Test search: ORD → LHR on ${departureDate} (1 adult, economy)...`);
try {
const offers = await adapter.searchFlights({
origin: 'ORD',
destination: 'LHR',
departureDate,
passengers: { adults: 1 },
cabinClass: 'economy',
});
console.log(` → ${offers.length} offers. Top 3:`);
for (const offer of offers.slice(0, 3)) {
const price = `${offer.totalPrice.amount} ${offer.totalPrice.currency}`;
console.log(` ${offer.validatingCarrier.padEnd(3)} ${price.padStart(12)} (offer ${offer.offerId.slice(0, 12)}…)`);
}
} catch (err) {
console.log(` → search failed: ${err instanceof Error ? err.message : String(err)}`);
}
}

// Step 4 — serve it for ChatGPT
const app = buildServer(adapter);
await app.listen({ port: PORT, host: '0.0.0.0' });
const base = publicBaseUrl().includes('localhost') ? `http://localhost:${PORT}` : `http://localhost:${PORT}`;
console.log('\n 4. Your inventory is now ChatGPT-ready. Server is live:\n');
console.log(` Landing page: ${base}/`);
console.log(` OpenAPI spec: ${base}/openapi.json ← import this into a ChatGPT Custom GPT Action`);
console.log('\n Try it:');
console.log(` curl -s -X POST ${base}/flights/search \\`);
console.log(` -H 'content-type: application/json' \\`);
console.log(` -d '{"origin":"ORD","destination":"LHR","departureDate":"${plusDays(14)}","passengers":{"adults":1},"cabinClass":"economy"}'`);
console.log('\n Ctrl+C to stop.\n');
}

main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
Loading
Loading