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
32 changes: 32 additions & 0 deletions .github/workflows/static-guards.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: static guards

on:
pull_request:
push:
branches: [main, DEV]

# Runs the static guard suite on every PR and push to main/DEV.
# This includes build collisions, icon/UI5 imports, srv-qa parity,
# slug-lookup canonicalization, CSRF, GraphQL breaking-changes, etc.
#
# Previously these only ran in deploy.yml, so violations introduced by a PR
# merged undetected and first surfaced at the next deploy. Running them as a
# separate gate now fails such a PR before merge.

jobs:
guards:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci --no-audit --no-fund
env:
NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }}
- name: Static build guards
run: npm run static-guards
11 changes: 0 additions & 11 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,3 @@ jobs:
run: npm run setup
- name: Run unit tests
run: npm test
- name: Static build guards (postbuild:apps)
# Runs the same static guard suite deploy.yml runs (build collisions,
# icon/UI5 imports, srv-qa parity, slug-lookup canonicalization, CSRF,
# GraphQL breaking-changes, …). These previously ran ONLY in deploy.yml,
# so guard violations introduced by a PR (whose gate was npm test only)
# merged undetected and first surfaced at the next workflow_dispatch
# deploy — which is how 41 unmarked slug lookups accumulated before a
# deploy caught them all at once. Running them here fails such a PR
# before merge. (postbuild:apps ends with check:graphql-breaking, so it
# subsumes the standalone GraphQL step that used to live here.)
run: npm run postbuild:apps
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default defineConfig({
{ text: 'Using Joule chat', link: '/end-users/using-joule-chat' },
{ text: 'Talking to Joule', link: '/end-users/joule-commands' },
{ text: 'MCP quickstart', link: '/end-users/mcp-quickstart' },
{ text: 'A2A quickstart', link: '/end-users/a2a-quickstart' },
{ text: 'Progress and completions', link: '/end-users/progress-and-completions' },
{ text: 'Privacy and cookies', link: '/end-users/privacy-and-cookies' },
{ text: 'Accessibility', link: '/end-users/accessibility' },
Expand Down
183 changes: 183 additions & 0 deletions docs/end-users/a2a-quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
---
title: A2A Quickstart
description: Consume the SAP Developers tutorial platform as an A2A (Agent-to-Agent) agent.
---

# A2A Quickstart

The SAP Developers site exposes a first-party **[A2A protocol](https://a2a-protocol.org)** agent
so a *central* SAP Joule instance — or another trusted BTP integration — can consume the
platform's Joule capabilities **agent-to-agent**. Unlike the [MCP server](./mcp-quickstart.md),
which exposes discrete tools a client orchestrates itself, A2A exposes the platform as a single
agent: you hand it a natural-language task and it runs its own agentic loop (search, knowledge
graph, progress) and returns a completed A2A `Task`.

> **Two audiences, one endpoint.** The Agent Card is **public** — anyone can discover the agent
> and its skills without a token. Actually *calling* a skill requires an XSUAA bearer with the
> `Tutorial.MCP` scope via **OAuth2 client-credentials** — a machine-to-machine flow that is
> provisioned by a BTP admin, not self-service. If you only want to inspect the agent, start at
> [Public discovery](#public-discovery) and stop there.

## Available skills

The Agent Card advertises five skills. Select one per request with `metadata.skillId`; omit it
to use `tutorial-chat`.

| Skill (`skillId`) | Auth | What it does |
|---|---|---|
| `tutorial-chat` | scope | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop. **Default**; supports streaming via `message/stream`. |
| `search-tutorials` | scope | Semantic/keyword search over the tutorial catalog. |
| `user-progress` | scope **+ forwarded user token** | The signed-in developer's tutorial/mission progress. Returns empty results if the end-user's identity is not forwarded. |
| `knowledge-graph` | scope | Concept expansion and learning-path reasoning over the tutorial knowledge graph. |
| `tutorial-steps` | scope | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. |

## Base URLs

- **Production:** `https://developers.sap.com` (cutover end of July 2026)
- **Dev:** ask your admin for the current dev route

Replace `<base>` in the examples below with the appropriate URL.

## Public discovery

Fetch the Agent Card — no auth, safe from a browser or `curl`:

```bash
curl <base>/.well-known/agent-card.json
```

Key fields:

- `url` — the JSON-RPC endpoint (`<base>/a2a`).
- `skills[]` — the five skills above, with `id`, `description`, `tags`, and `examples`.
- `securitySchemes.xsuaa.flows.clientCredentials.tokenUrl` — the XSUAA token endpoint you
authenticate against (see below).
- `capabilities.streaming` — `true` (SSE via `message/stream`).
- `documentationUrl` — points at `<base>/.well-known/a2a-instructions.md`, the canonical
consumption guide.
- `metadata.available` — `false` when an admin has disabled A2A; when disabled, `POST /a2a`
returns **HTTP 503**.

## Authentication (internal / partner)

All `/a2a` calls require an XSUAA bearer carrying the **`Tutorial.MCP`** scope, obtained via
**OAuth2 client-credentials**. There is no PAT or interactive PKCE path — this is a
machine-to-machine flow.

### 1. Get client credentials

You need a `client_id` / `client_secret` pair whose XSUAA instance is granted the `Tutorial.MCP`
scope. There are two realistic paths:

- **Platform team (same app):** use the tutorial platform's own XSUAA service key, which already
owns the scope. Read it from the bound instance:

```bash
# dev
cf env tutorials-srv | sed -n '/xsuaa/,/}/p' # → VCAP_SERVICES.xsuaa[0].credentials
# prod
cf env tutorials-prod-srv | sed -n '/xsuaa/,/}/p'
```

The `credentials` object carries `clientid`, `clientsecret`, and `url` (the XSUAA tenant base;
append `/oauth/token`). The `clientid` is environment-specific — `sb-tutorials!…` on dev,
`sb-tutorials-prod!…` on prod (dev and prod share one XSUAA tenant, so prod uses the distinct
`tutorials-prod` xsappname).

- **A separate consumer (e.g. a standalone central Joule):** the consumer's own XSUAA instance
must be granted the `Tutorial.MCP` scope as a foreign-authority grant. **This is a BTP-admin
step** (an `xs-security.json` `granted-apps` / authority grant on the tutorial platform's side,
matched by the consumer's `xs-security.json`). It is not self-service — open an issue or contact
the platform team to arrange the grant for your subaccount. The exact grant recipe is
intentionally not reproduced here; it depends on the consuming app's xsappname.

### 2. Exchange for a token

```bash
export TOKEN_URL="$(curl -s <base>/.well-known/agent-card.json \
| jq -r '.securitySchemes.xsuaa.flows.clientCredentials.tokenUrl')"

export TOKEN="$(curl -s "$TOKEN_URL" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d 'grant_type=client_credentials' | jq -r '.access_token')"
```

For `user-progress`, additionally forward the end-user's identity token (the platform reads the
end-user from it); a pure client-credentials token yields empty progress results.

## Calling the agent

JSON-RPC 2.0 over `POST <base>/a2a`.

### `message/send` (synchronous)

Returns a completed `Task` with results in `result.artifacts`:

```bash
curl -X POST <base>/a2a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","parts":[{"kind":"text","text":"Find CAP tutorials"}]},
"metadata":{"skillId":"search-tutorials"}}}'
```

### `message/stream` (SSE — chat)

Omit `skillId` (or set `tutorial-chat`) and call `message/stream`. The response is an SSE stream of
A2A events: `status-update` (state `working` → `completed`), `artifact-update` (tutorial cards,
citations), and a final `status-update` with `final: true`.

```bash
curl -N -X POST <base>/a2a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/stream",
"params":{"message":{"role":"user","parts":[{"kind":"text","text":"How do I get started with CAP?"}]}}}'
```

### `tasks/get`, `tasks/cancel`

```bash
curl -X POST <base>/a2a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"<taskId>"}}'
```

Task snapshots are retained ~15 minutes and are coherent across server instances.

## Choosing a skill

- Free-form questions / multi-step reasoning → omit `skillId` (uses `tutorial-chat`, which
internally routes to search, knowledge graph, and progress).
- A single known capability → set `metadata.skillId` to one of the four discrete skills.

## Errors

Standard JSON-RPC 2.0 error objects:

| Code | Meaning | HTTP |
|---|---|---|
| `-32001` | Auth required (missing/invalid bearer or scope) | 401 |
| `-32601` | Unknown method | 200 (JSON-RPC error) |
| `-32602` | Bad params / unknown `skillId` | 200 (JSON-RPC error) |
| `-32603` | Internal error | 200 (JSON-RPC error) |

When A2A is disabled by an admin, `POST /a2a` returns **HTTP 503** and the Agent Card sets
`metadata.available: false`.

## Admin configuration

A2A is configured by an admin at **`/admin-ui/#joule`** (the "A2A (Agent-to-Agent) Endpoint" panel
on the Joule settings page), stored on the `ChatSettings` singleton — these are DB-backed settings,
not environment variables, and changes take effect within ~5 seconds without a restart:

- **A2A Enabled** — master switch; off → `POST /a2a` returns 503 and the card signals unavailability.
- **Public Base URL** — the base advertised in the Agent Card `url`; blank auto-detects from
`VCAP_APPLICATION.application_uris`.
- **OAuth Token URL** — the XSUAA token endpoint advertised in the card's security scheme.

## See also

- Canonical served guide: [`/.well-known/a2a-instructions.md`](https://github.com/sap-tutorials/tutorials-ims/blob/main/srv/mcp/a2a-instructions.md)
- [MCP Quickstart](./mcp-quickstart.md) — the complementary tool-oriented surface.
- [API landing page](https://developers.sap.com/api-docs/) — all published surfaces.
47 changes: 46 additions & 1 deletion hugo/content/api-docs/_index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: API
description: developers.sap.com is a developer site — so it's accessible via API too. HTTP services, a hosted MCP server, the sap-devs CLI, and feeds you can script against.
description: developers.sap.com is a developer site — so it's accessible via API too. HTTP services, a hosted MCP server, an A2A agent, the sap-devs CLI, and feeds you can script against.
weight: 35
---

Expand Down Expand Up @@ -136,6 +136,51 @@ claude mcp add sap-devs-server -- sap-devs mcp serve

Once connected, ask your agent "what's new in SAP" or paste an SAP error and it will resolve against the live content instead of stale training data.

## A2A agent

Alongside the MCP server, this site exposes a first-party **[A2A protocol](https://a2a-protocol.org)** agent — so a *central* SAP Joule instance (or another trusted BTP integration) can consume the platform's Joule capabilities **agent-to-agent**, without going through MCP. Same brain as the site's own Joule; different wire protocol.

MCP and A2A are complementary, not alternatives:

- **MCP** exposes discrete *tools* an AI client calls directly (search, graph, your progress). Good for a client that wants to orchestrate the tools itself.
- **A2A** exposes the platform as a single *agent* with named *skills*. A calling agent hands over a natural-language task and gets back a completed A2A `Task` (or a stream of events) — the platform runs its own agentic loop internally.

### Discovery

The **Agent Card** is public — no token:

```bash
curl <base>/.well-known/agent-card.json
```

It advertises the endpoint URL, security scheme, streaming capability, and the five skills below, and links its own consumption guide at `<base>/.well-known/a2a-instructions.md`. When A2A is disabled by an admin, the card sets `metadata.available: false` and `POST /a2a` returns HTTP 503.

| Skill (`skillId`) | What it does |
|---|---|
| `tutorial-chat` | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop (search + graph + progress). **Default** when no `skillId` is set; supports streaming. |
| `search-tutorials` | Semantic/keyword search over the tutorial catalog. |
| `user-progress` | The signed-in developer's tutorial/mission progress. Needs the end-user's identity forwarded in the token; returns empty otherwise. |
| `knowledge-graph` | Concept expansion and learning-path reasoning over the tutorial knowledge graph. |
| `tutorial-steps` | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. |

### Transport

JSON-RPC 2.0 over `POST <base>/a2a`. Synchronous `message/send` returns a completed `Task` with results in `result.artifacts`; `message/stream` returns an SSE event stream (used by `tutorial-chat`). `tasks/get` and `tasks/cancel` operate on a task id.

```bash
curl -X POST <base>/a2a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","parts":[{"kind":"text","text":"Find CAP tutorials"}]},
"metadata":{"skillId":"search-tutorials"}}}'
```

### Auth

Every `/a2a` call needs an XSUAA bearer carrying the `Tutorial.MCP` scope, obtained via **OAuth2 client-credentials** against the `tokenUrl` in the card's `securitySchemes.xsuaa`. This is a machine-to-machine flow — there is **no** self-service PAT/PKCE path like the MCP signed-in tools. The Agent Card itself is public; `user-progress` additionally needs the end-user's identity forwarded.

Full connection walkthrough (public probe, client-credentials, streaming, errors, Joule wiring): [A2A Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/a2a-quickstart.md). Canonical served guide: [`/.well-known/a2a-instructions.md`](https://github.com/sap-tutorials/tutorials-ims/blob/main/srv/mcp/a2a-instructions.md).

## Feeds

{{< api-endpoint-table section="feeds" >}}
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@
"build:island-manifest": "node scripts/build-island-manifest.cjs",
"check:ui5-single-copy": "node scripts/check-ui5-single-copy.cjs",
"retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json",
"postbuild:apps": "tsx scripts/check-build-collisions.ts && tsx scripts/check-icon-imports.ts && tsx scripts/check-island-ui5-imports.ts && tsx scripts/check-xs-app-mta.ts && tsx scripts/check-public-endpoints.ts && tsx scripts/check-srv-qa-cp-list.ts && tsx scripts/check-srv-qa-route-drift.ts && tsx scripts/check-srv-qa-dep-parity.ts && tsx scripts/check-slug-lookups.ts && tsx scripts/check-ui5-controller-extensions.ts && tsx scripts/check-kg-meta-formatters-mirror.ts && tsx scripts/check-csrf-clients.ts && npm run check:graphql-breaking",
"prepare": "npm run setup:git-hooks",
"setup:git-hooks": "sh scripts/install-git-hooks.sh",
"static-guards": "tsx scripts/run-static-guards.ts",
"postbuild:apps": "npm run static-guards",
"build:explore-manifest": "tsx scripts/build-explore-manifest.ts",
"build:explore": "npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/explore run build && npm run build:explore-manifest",
"fetch-channel-atlas": "tsx scripts/fetch-channel-atlas.ts",
Expand Down
41 changes: 40 additions & 1 deletion scripts/check-icon-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
// complete (parser regression). Stderr lists missing names with
// file:line refs and the one-line fix.

import { readFileSync, readdirSync } from 'node:fs';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join, resolve, relative, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

Expand All @@ -62,6 +62,9 @@ const REPO_ROOT = process.env.CHECK_ICON_IMPORTS_ROOT
const HUGO_LAYOUTS_DIR = join(REPO_ROOT, 'hugo', 'layouts');
const HUGO_ASSETS_JS_DIR = join(REPO_ROOT, 'hugo', 'assets', 'js');
const HUGO_APPS_SRC_DIR = join(REPO_ROOT, 'hugo-apps', 'src');
const BOOTSTRAP_PATH = join(HUGO_ASSETS_JS_DIR, 'ui5-bootstrap.ts');

const FIX = process.argv.includes('--fix');

export interface IconUsage {
/** Icon name as written, e.g. "bbyd-active-sales". */
Expand Down Expand Up @@ -253,6 +256,42 @@ function main(): void {
byName.set(u.name, arr);
}

if (FIX) {
// Zero-judgment fix: each missing icon needs exactly one side-effect
// import, and the name is fully determined by the usage. Insert the
// imports directly after the last existing icon import in the bootstrap
// so they stay grouped with the other icon registrations.
const names = [...byName.keys()].sort();
let src: string;
try {
src = readFileSync(BOOTSTRAP_PATH, 'utf8');
} catch (err) {
console.error(`[check-icon-imports] --fix could not read ${BOOTSTRAP_PATH}:`, err);
process.exit(1);
}
const eol = src.includes('\r\n') ? '\r\n' : '\n';
const lines = src.split(/\r?\n/);
const ICON_IMPORT_RE = /@ui5\/webcomponents-icons\/dist\/[a-z][a-z0-9-]*\.js/;
let lastIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (ICON_IMPORT_RE.test(lines[i])) lastIdx = i;
}
if (lastIdx === -1) {
console.error(
`[check-icon-imports] --fix found no existing icon import in ${BOOTSTRAP_PATH} to anchor to. Add the imports manually.`
);
process.exit(1);
}
const newLines = names.map(n => `import "@ui5/webcomponents-icons/dist/${n}.js";`);
lines.splice(lastIdx + 1, 0, ...newLines);
writeFileSync(BOOTSTRAP_PATH, lines.join(eol));
console.log(
`[check-icon-imports] FIXED — added ${names.length} icon import(s) to ${BOOTSTRAP_PATH}:`
);
for (const n of names) console.log(` import "@ui5/webcomponents-icons/dist/${n}.js";`);
process.exit(0);
}

console.error('[check-icon-imports] FAILED — unregistered UI5 icon(s):');
console.error('');
for (const [name, usagesForName] of byName) {
Expand Down
Loading
Loading