Custom Skills
SAP Build Code
GA

Joule Studio

The developer and low-code authoring environment on SAP BTP for building custom Joule skills — extending SAP Joule with organisation-specific intents, actions against SAP and third-party APIs, grounded knowledge sources, and multi-step agent conversation flows.

What is Joule Studio?

Joule Studio is the BTP-based environment for authoring custom Joule skills — capability handlers that extend SAP Joule beyond its SAP-delivered catalogue. A skill defines which natural language intents it handles, what entities it extracts from the user utterance, how it calls backend APIs, and how it constructs the structured response shown in the Joule panel.

Skills are implemented in TypeScript using the Joule Skill SDK, developed inside SAP Build Code (the BTP-hosted AI-assisted IDE), and tested in the Joule Studio Test Console before being published to the organisation's Joule skill catalogue.

Clean Core compliance
Custom Joule skills must only consume released SAP public APIs — OData V4 services listed on the SAP API Business Hub with a C1 release contract. This preserves Clean Core compliance for the AI layer: skills break on upgrade only if SAP deprecates a released API (which is communicated with a deprecation notice), never due to internal system changes.

Quick Facts

Product
Joule Studio
Part of
SAP Build Code (BTP)
Skill language
TypeScript / JavaScript (Node.js)
SDK
@sap/joule-skill-sdk
API constraint
Released OData V4 / REST only
Identity
Principal propagation via IAS/XSUAA
Knowledge
RAG — vector search on linked documents
Testing
Built-in Test Console + JSON test cases
Deployment
Publish to BTP Joule tenant catalogue
Availability
Generally Available

Video Tutorials

Official SAP channel walkthroughs — click any card to play

Joule Studio — Build Your First Skill20:15

Joule Studio — Build Your First Skill

SAP Developers
Beginner
Joule Studio — Knowledge Source Config14:55

Joule Studio — Knowledge Source Config

SAP Developers
Intermediate
Joule Studio — Action Integration25:08

Joule Studio — Action Integration

SAP Learning
Advanced

Joule Studio Architecture

Joule Studio — Authoring and Runtime Architecture
Rendering diagram…

Skills

A Skill is the core unit of Joule extension. It bundles the intent definitions (what natural language triggers it), entity slots (what information is extracted), the handler implementation (TypeScript logic), and the response format (card, list, message, confirmation, clarification).

01

Define Intents

Name each intent and provide 5–10 sample utterances (training phrases). The NLU engine learns from these examples. More utterances with diverse phrasing = better intent recognition accuracy.

02

Configure Entity Slots

Declare named entity slots (e.g., leaveType, startDate, purchaseOrderNumber). Mark required slots — Joule will ask the user for missing required entities via clarification prompts (slot-filling).

03

Implement Handler

Write the skill handler in TypeScript. Use ctx.callApi() for backend calls (principal propagation is automatic), ctx.clarify() for slot-filling, ctx.confirm() for write-before-confirm, and ctx.respond() to return the result.

04

Publish to Catalogue

Test in the Test Console, then publish the skill version to the organisation's Joule tenant. The skill becomes available to all users with the correct XSUAA role collection assigned.

skill-manifest.json — Skill descriptor and intent definitions
1// Joule Studio — Skill descriptor and intent definitions
2// File: skill-manifest.json (registered in Joule Studio)
3
4{
5  "skillId": "com.company.hr.leave-assistant",
6  "displayName": "HR Leave Assistant",
7  "description": "Handles leave balance queries, leave booking, team calendar checks, and leave request status.",
8  "version": "1.2.0",
9  "author": "HR Technology Team",
10  "requiredScopes": ["SuccessFactors.TimeAccount.Read", "SuccessFactors.LeaveRequest.Write"],
11
12  "intents": [
13    {
14      "id": "checkLeaveBalance",
15      "displayName": "Check Leave Balance",
16      "description": "Returns the user's current leave balance by leave type",
17      "sampleUtterances": [
18        "How many days of annual leave do I have?",
19        "What is my remaining leave balance?",
20        "Show me my leave entitlement",
21        "How much sick leave is left?",
22        "Check my time off balance"
23      ],
24      "entities": [
25        {
26          "name": "leaveType",
27          "type": "enum",
28          "required": false,
29          "values": ["Annual", "Sick", "Unpaid", "Compassionate"],
30          "description": "Type of leave to check — defaults to all types if not specified"
31        }
32      ]
33    },
34    {
35      "id": "bookLeave",
36      "displayName": "Book Leave",
37      "description": "Creates a leave request for the specified date range",
38      "sampleUtterances": [
39        "Book me leave next Monday and Tuesday",
40        "I need annual leave from the 10th to the 14th of next month",
41        "Request sick leave for today",
42        "Apply for 3 days of annual leave starting next week",
43        "I want to take Friday off"
44      ],
45      "entities": [
46        {
47          "name": "startDate",
48          "type": "date",
49          "required": true,
50          "description": "First day of leave — resolved relative to today"
51        },
52        {
53          "name": "endDate",
54          "type": "date",
55          "required": false,
56          "description": "Last day of leave — defaults to startDate for single-day requests"
57        },
58        {
59          "name": "leaveType",
60          "type": "enum",
61          "required": false,
62          "values": ["Annual", "Sick", "Unpaid"],
63          "description": "Type of leave — defaults to Annual if not specified"
64        }
65      ]
66    },
67    {
68      "id": "checkLeaveStatus",
69      "displayName": "Check Leave Request Status",
70      "description": "Returns the approval status of a pending or recent leave request",
71      "sampleUtterances": [
72        "What is the status of my leave request?",
73        "Has my leave been approved?",
74        "Show me my pending leave requests",
75        "Is my holiday next week confirmed?"
76      ],
77      "entities": [
78        {
79          "name": "requestId",
80          "type": "string",
81          "required": false,
82          "description": "Leave request ID — if omitted, returns most recent pending request"
83        }
84      ]
85    }
86  ]
87}

Actions

Actions are the API calls made by a skill during execution. Each action is a call to a backend system via a BTP Destination. Joule Studio Action Editor provides a visual way to define REST and OData actions; for full control, actions are called programmatically via ctx.callApi() in the skill handler.

Read Actions (GET)

  • Fetch entity by key — single record
  • Fetch collection with $filter/$select/$top
  • $expand to include navigation properties
  • OData V4 functions (bound and unbound)
  • REST GET with query parameters

Write Actions (POST / PATCH)

  • Create entity — OData V4 POST with body
  • Update fields — OData V4 PATCH
  • OData V4 bound actions (e.g., Approve, Submit)
  • REST POST for non-OData backends
  • Always preceded by ctx.confirm() — user must confirm writes

Authentication (via Destination)

  • OAuth2 SAML Bearer — principal propagation to S/4HANA / SF
  • OAuth2 Client Credentials — service-to-service (Integration Suite)
  • Basic Authentication — on-premise via Cloud Connector (legacy)
  • API Key — third-party REST services (header injected by destination)
  • Destination name referenced in ctx.callApi({ destination: "NAME" })

Knowledge Sources

Knowledge Sources are indexed document collections that a skill can search to ground its responses in organisation-specific content — IT policies, HR handbooks, product FAQs, compliance documents. Knowledge retrieval uses Retrieval-Augmented Generation (RAG): relevant chunks are retrieved by vector similarity search, then passed to the Generative AI Hub as context for a grounded response.

Knowledge Sources — Availability
Knowledge Source integration in Joule Studio is a capability available via the SAP Joule platform. Check the SAP Help Portal and Discovery Center for the current feature availability in your SAP Joule and SAP Build Code subscription tier.

Supported Document Formats

  • PDF documents
  • Microsoft Word (.docx)
  • Plain text (.txt)
  • Markdown (.md)
  • HTML pages (crawled)

Indexing Pipeline

  • Document ingestion via Joule Studio upload or URL
  • Text chunking (configurable chunk size and overlap)
  • Embedding generation (SAP AI Foundation vector model)
  • Vector index stored in BTP AI Core (HANA Vector Engine)

Retrieval Configuration

  • Top-K results (default: 3)
  • Minimum relevance score threshold
  • Source attribution in response (document title + URL)
  • Knowledge source scoped per skill or shared across skills

RAG Response Generation

  • Retrieved chunks passed as context to LLM
  • System prompt instructs LLM to answer from context only
  • LLM does not hallucinate beyond retrieved content
  • Response cites source document(s) shown to user

Grounding

Grounding is the practice of constraining LLM responses to content derived from specific, authoritative data sources — preventing hallucination and ensuring business accuracy. Joule Studio supports two grounding modes that can be combined in a single skill.

API Grounding — Live Business Data

The skill calls a SAP backend API and passes the API response as context to the LLM. The LLM is instructed to answer only from the retrieved data — it summarises, formats, or narrates the API result rather than generating from training data.

  • Real-time data — response reflects current system state
  • Principal propagation ensures user-specific data only
  • Authorisation enforced by ABAP/OData layer
  • Ideal for: status queries, list summaries, comparisons
  • Example: "Summarise my top 5 overdue invoices"

Knowledge Source Grounding — Documents

The skill performs a vector similarity search against the indexed knowledge source and passes the top-K relevant chunks to the LLM. Responses cite the source document so users can verify.

  • Answers from policies, procedures, FAQs, handbooks
  • Updated by re-indexing the document — no skill redeployment
  • Source attribution prevents unverifiable answers
  • Ideal for: "How do I submit an expense claim?", IT support FAQs
  • Fallback: "No relevant article found" + offer to raise incident

Context Management

The Joule Context Manager maintains three types of context that skills receive in the ctx object — enabling natural multi-turn conversations and personalised, contextual responses.

Joule Studio — Context Management: Session, User, and Application Context
Rendering diagram…

Session Context

ctx.sessionContext / ctx.setSessionContext()
  • "the order I just mentioned" — references last entity
  • "and Q2 as well?" — follow-up without repeating parameters
  • Slot values filled in previous turns reused
  • Last intent stored for disambiguation
  • Session expires when Joule panel is closed

User Context

ctx.user — populated from IAS attributes
  • ctx.user.id — SAP user ID (for API calls)
  • ctx.user.email — email address
  • ctx.user.displayName — full name
  • ctx.user.attributes.department — from IPS
  • ctx.user.attributes.costCenter — from HR system

Application Context

ctx.appContext — from Fiori Launchpad
  • ctx.appContext.currentApp — open Fiori app ID
  • ctx.appContext.currentRecord — displayed record key
  • ctx.appContext.filters — active filter state
  • "this purchase order" resolves to open record
  • Enables context-aware responses without user repeating IDs

Agent Design

Agent design in Joule Studio refers to structuring skill handlers to manage multi-turn conversational flows — slot-filling dialogs, confirmation gates before write operations, escalation fallbacks, and cross-intent context passing. The Joule Skill SDK provides built-in primitives for all of these patterns.

Agent Design — Leave Booking Multi-Turn Conversation Flow
Rendering diagram…

Slot Filling

ctx.clarify(question)

Return a clarification response when a required entity is missing. Joule presents the question to the user and re-invokes the skill with the filled slot on the next turn.

"Which date would you like to start your leave?"

Confirmation Gate

ctx.confirm({ ... })

Required before any write operation (POST/PATCH/DELETE). Presents a confirmation card to the user. The onConfirm callback executes the write; onCancel returns a cancellation message.

"Confirm booking 2 days annual leave 10–11 Jun?"

Session Handoff

ctx.setSessionContext({ key: value })

Store entity values in session context so subsequent turns can reference "that order" or "this project" without the user repeating identifiers across intents.

lastProjectId stored after projectMilestones; reused by budgetStatus

Intent Chaining

actions: [{ intent: "intentName" }]

Include a follow-up action button in the response that triggers a different intent. Enables natural next-step navigation: "Book Leave" button on checkLeaveBalance response.

"Still need help? Raise Incident" → createIncident intent

Testing

Joule Studio provides a Test Console for interactive NLU testing and a JSON test case format for automated regression testing. The recommended pipeline has four stages — unit tests in the console, integration tests against a sandbox backend, staging with real users, and production monitoring.

Joule Skill Testing Pipeline — From Console to Production
Rendering diagram…

Unit — Test Console

  • Type utterance → inspect intent + entities
  • Verify all required entities extracted
  • Mock API responses via test configuration
  • Check response type and card properties
  • Run stored test cases automatically

Integration — Sandbox

  • Live calls to sandbox S/4HANA or SF tenant
  • Verify principal propagation forwards user JWT
  • Test 403 for data outside user auth
  • Test empty result handling
  • Verify session context persistence

Staging — Pre-Prod

  • Real users on representative data
  • p95 response time target: < 3 seconds
  • Edge cases: special chars, long strings, null fields
  • Confirm deep-links navigate correctly
  • User acceptance sign-off

Production — Monitor

  • Intent confidence score distribution
  • Error rate per intent
  • p95 latency via BTP Observability
  • Skill version rollback available
  • NLU drift detection: retrain if accuracy drops
skill-tests.json — Test Console test cases for HR Leave Assistant
1// Joule Studio — Test Console configuration and test cases
2// File: skill-tests.json — run via Joule Studio Test Console or CI/CD
3
4{
5  "skillId": "com.company.hr.leave-assistant",
6  "testSuite": "HR Leave Assistant — Full Test Suite",
7  "tests": [
8    {
9      "id": "TC-001",
10      "intent": "checkLeaveBalance",
11      "description": "Check all leave balances — no leave type specified",
12      "utterance": "How much leave do I have?",
13      "expectedIntent": "checkLeaveBalance",
14      "expectedEntities": {},
15      "expectedResponseType": "list",
16      "apiMocks": {
17        "SF_ODATA:/odata/v2/TimeAccount": {
18          "d": {
19            "results": [
20              { "externalCode": "AL-2025", "timeAccountType": { "externalName_defaultValue": "Annual Leave" }, "balance": "12.5", "unit": "days", "endDate": "2025-12-31" },
21              { "externalCode": "SL-2025", "timeAccountType": { "externalName_defaultValue": "Sick Leave" }, "balance": "5.0", "unit": "days", "endDate": "2025-12-31" }
22            ]
23          }
24        }
25      },
26      "assertions": [
27        { "path": "response.type", "equals": "list" },
28        { "path": "response.items.length", "greaterThan": 0 },
29        { "path": "response.title", "contains": "Leave Balance" }
30      ]
31    },
32    {
33      "id": "TC-002",
34      "intent": "bookLeave",
35      "description": "Book annual leave with both dates specified",
36      "utterance": "Book me annual leave from Monday the 10th to Wednesday the 12th",
37      "expectedIntent": "bookLeave",
38      "expectedEntities": {
39        "leaveType": "Annual"
40      },
41      "expectedResponseType": "confirm",
42      "assertions": [
43        { "path": "response.type", "equals": "confirm" },
44        { "path": "response.title", "equals": "Confirm Leave Booking" }
45      ]
46    },
47    {
48      "id": "TC-003",
49      "intent": "bookLeave",
50      "description": "Book leave — no start date (should trigger clarification)",
51      "utterance": "I want to book some leave",
52      "expectedIntent": "bookLeave",
53      "expectedEntities": {},
54      "expectedResponseType": "clarification",
55      "assertions": [
56        { "path": "response.type", "equals": "clarification" },
57        { "path": "response.question", "contains": "date" }
58      ]
59    },
60    {
61      "id": "TC-004",
62      "intent": "bookLeave",
63      "description": "Book leave — insufficient balance (should respond with balance warning)",
64      "utterance": "Book me 20 days of annual leave starting next Monday",
65      "expectedIntent": "bookLeave",
66      "apiMocks": {
67        "SF_ODATA:/odata/v2/TimeAccount": {
68          "d": { "results": [{ "balance": "3.0", "unit": "days" }] }
69        }
70      },
71      "expectedResponseType": "message",
72      "assertions": [
73        { "path": "response.type", "equals": "message" },
74        { "path": "response.text", "contains": "Insufficient balance" }
75      ]
76    },
77    {
78      "id": "TC-005",
79      "intent": "checkLeaveStatus",
80      "description": "Check leave status — no ticket ID (returns recent requests)",
81      "utterance": "What is the status of my leave request?",
82      "expectedIntent": "checkLeaveStatus",
83      "expectedEntities": {},
84      "expectedResponseType": "list"
85    }
86  ]
87}

Complete Examples

Four production-pattern skills demonstrating the full range of Joule Studio capabilities — slot filling, confirmation gates, multi-turn context, knowledge source grounding, and multi-entity data aggregation.

HR Leave Assistant

Generally Available

SAP SuccessFactors — Time Management

checkLeaveBalancebookLeavecheckLeaveStatus
Backend: SuccessFactors OData V2 (TimeAccount, LeaveRequest)

How much annual leave do I have?

checkLeaveBalance

No entities needed — returns all balances

Book me leave next Monday to Wednesday

bookLeave

startDate + endDate extracted; confirm gate before POST

I want to take Friday off

bookLeave

Single-day leave; endDate defaults to startDate

Has my leave been approved?

checkLeaveStatus

Returns most recent pending requests

hr-leave-assistant.skill.ts
1// Joule Studio — HR Leave Assistant skill handler
2// File: hr-leave-assistant.skill.ts
3// SDK: @sap/joule-skill-sdk  |  Runtime: Node.js on BTP
4
5import type {
6  JouleSkill, JouleIntent, JouleContext,
7  SkillResponse, CardResponse,
8} from '@sap/joule-skill-sdk'
9
10// ── Skill descriptor (registered in Joule Studio) ─────────────────────────────
11export const descriptor = {
12  skillId: 'com.company.hr.leave-assistant',
13  version: '1.2.0',
14  intents: ['checkLeaveBalance', 'bookLeave', 'checkLeaveStatus'],
15}
16
17// ── Intent router ─────────────────────────────────────────────────────────────
18export async function execute(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
19  switch (intent.id) {
20    case 'checkLeaveBalance': return checkLeaveBalance(intent, ctx)
21    case 'bookLeave':         return bookLeave(intent, ctx)
22    case 'checkLeaveStatus':  return checkLeaveStatus(intent, ctx)
23    default:
24      return ctx.respond({ type: 'message', text: 'I did not understand that leave request. Try "How much annual leave do I have?"' })
25  }
26}
27
28// ── Handler: Check leave balance ─────────────────────────────────────────────
29async function checkLeaveBalance(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
30  const leaveType = intent.entities.leaveType ?? null
31
32  // SuccessFactors Time Account OData V4
33  const filter = leaveType
34    ? `userId eq '${ctx.user.id}' and timeAccountType/externalCode eq '${leaveType}'`
35    : `userId eq '${ctx.user.id}'`
36
37  const result = await ctx.callApi({
38    destination: 'SF_ODATA',
39    path: '/odata/v2/TimeAccount',
40    params: {
41      '$filter': filter,
42      '$select': 'externalCode,timeAccountType,balance,unit,endDate',
43    },
44  })
45
46  const accounts = result.d?.results ?? []
47  if (!accounts.length) {
48    return ctx.respond({ type: 'message', text: 'No leave accounts found for your user profile.' })
49  }
50
51  return ctx.respond({
52    type: 'list',
53    title: leaveType ? `${leaveType} Leave Balance` : 'Your Leave Balances',
54    items: accounts.map(acc => ({
55      title: acc.timeAccountType?.externalName_defaultValue ?? acc.externalCode,
56      properties: [
57        { label: 'Balance', value: `${acc.balance} ${acc.unit ?? 'days'}`, highlight: parseFloat(acc.balance) < 3 },
58        { label: 'Valid Until', value: acc.endDate ?? 'No expiry' },
59      ],
60    })),
61    actions: [
62      { label: 'Book Leave', intent: 'bookLeave' },
63      { label: 'View in SuccessFactors', deepLink: '/sf/timeoff' },
64    ],
65  })
66}
67
68// ── Handler: Book leave ───────────────────────────────────────────────────────
69async function bookLeave(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
70  const { startDate, endDate, leaveType = 'Annual' } = intent.entities
71
72  // Require slot-filling if start date is missing
73  if (!startDate) {
74    return ctx.clarify('Which date would you like to start your leave?')
75  }
76
77  const resolvedEndDate = endDate ?? startDate
78  const duration = workingDaysBetween(startDate, resolvedEndDate)
79
80  // Check balance before creating request
81  const balanceResult = await ctx.callApi({
82    destination: 'SF_ODATA',
83    path: '/odata/v2/TimeAccount',
84    params: {
85      '$filter': `userId eq '${ctx.user.id}' and timeAccountType/externalCode eq '${leaveType}'`,
86      '$select': 'balance',
87    },
88  })
89  const balance = parseFloat(balanceResult.d?.results?.[0]?.balance ?? '0')
90
91  if (balance < duration) {
92    return ctx.respond({
93      type: 'message',
94      text: `Insufficient balance. You need ${duration} days but only have ${balance} ${leaveType} days remaining.`,
95    })
96  }
97
98  // Confirm before creating — write operation requires user confirmation
99  return ctx.confirm({
100    title: 'Confirm Leave Booking',
101    message: `Book ${duration} day(s) of ${leaveType} leave from ${formatDate(startDate)} to ${formatDate(resolvedEndDate)}?`,
102    onConfirm: async () => {
103      const createResult = await ctx.callApi({
104        destination: 'SF_ODATA',
105        method: 'POST',
106        path: '/odata/v2/LeaveRequest',
107        body: {
108          userId: ctx.user.id,
109          leaveType,
110          startDate: startDate,
111          endDate: resolvedEndDate,
112          requestedByUserId: ctx.user.id,
113          notes: 'Requested via SAP Joule',
114        },
115      })
116
117      return ctx.respond({
118        type: 'card',
119        title: 'Leave Request Submitted',
120        properties: [
121          { label: 'Request ID', value: createResult.d.externalCode, highlight: true },
122          { label: 'Type', value: leaveType },
123          { label: 'From', value: formatDate(startDate) },
124          { label: 'To', value: formatDate(resolvedEndDate) },
125          { label: 'Duration', value: `${duration} day(s)` },
126          { label: 'Status', value: 'Pending Manager Approval' },
127        ],
128        actions: [
129          { label: 'View Request', deepLink: `/sf/timeoff/request/${createResult.d.externalCode}` },
130        ],
131      })
132    },
133    onCancel: async () => ctx.respond({ type: 'message', text: 'Leave booking cancelled.' }),
134  })
135}
136
137// ── Handler: Check leave status ───────────────────────────────────────────────
138async function checkLeaveStatus(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
139  const filter = intent.entities.requestId
140    ? `externalCode eq '${intent.entities.requestId}'`
141    : `userId eq '${ctx.user.id}' and approvalStatus ne 'APPROVED' and approvalStatus ne 'CANCELLED'`
142
143  const result = await ctx.callApi({
144    destination: 'SF_ODATA',
145    path: '/odata/v2/LeaveRequest',
146    params: { '$filter': filter, '$top': '5', '$orderby': 'startDate desc' },
147  })
148
149  const requests = result.d?.results ?? []
150  if (!requests.length) {
151    return ctx.respond({ type: 'message', text: 'No pending leave requests found.' })
152  }
153
154  return ctx.respond({
155    type: 'list',
156    title: 'Your Leave Requests',
157    items: requests.map(r => ({
158      title: `${r.leaveType}${formatDate(r.startDate)} to ${formatDate(r.endDate)}`,
159      properties: [
160        { label: 'Status', value: r.approvalStatus, highlight: r.approvalStatus === 'PENDING' },
161        { label: 'Request ID', value: r.externalCode },
162      ],
163    })),
164  })
165}
166
167function workingDaysBetween(start: string, end: string): number {
168  // Simplified — production would call a working calendar API
169  const msPerDay = 86400000
170  const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / msPerDay) + 1
171  return Math.max(1, days)
172}
173function formatDate(iso: string): string {
174  return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
175}

Procurement Assistant

Generally Available

SAP S/4HANA — Purchasing (MM)

purchaseOrderStatusvendorPaymentStatuscreatePurchaseRequisition
Backend: S/4HANA OData V4 — A_PurchaseOrder, A_SupplierInvoice, A_PurReqn

Show me PO 4500012345

purchaseOrderStatus

PO number extracted; parallel header + item fetch

What items are open on that PO?

purchaseOrderStatus

Session context: lastMentionedPO resolves "that PO"

Open invoices for vendor 100?

vendorPaymentStatus

Returns overdue invoices highlighted in red

Create a PR for 50 units of FG100 at plant 1000

createPurchaseRequisition

Multi-entity; confirm gate; OData V4 POST

procurement-assistant.skill.ts
1// Joule Studio — Procurement Assistant skill handler
2// Intents: purchaseOrderStatus, vendorPaymentStatus, createPurchaseRequisition
3
4import type { JouleIntent, JouleContext, SkillResponse } from '@sap/joule-skill-sdk'
5
6export const descriptor = {
7  skillId: 'com.company.procurement.assistant',
8  version: '2.0.0',
9  intents: ['purchaseOrderStatus', 'vendorPaymentStatus', 'createPurchaseRequisition'],
10}
11
12export async function execute(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
13  switch (intent.id) {
14    case 'purchaseOrderStatus':       return purchaseOrderStatus(intent, ctx)
15    case 'vendorPaymentStatus':       return vendorPaymentStatus(intent, ctx)
16    case 'createPurchaseRequisition': return createPurchaseRequisition(intent, ctx)
17    default: return ctx.respond({ type: 'message', text: 'I can help with PO status, vendor payments, or creating requisitions.' })
18  }
19}
20
21// ── PO Status ─────────────────────────────────────────────────────────────────
22async function purchaseOrderStatus(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
23  const poNumber = intent.entities.purchaseOrderNumber
24    ?? ctx.sessionContext?.lastMentionedPO
25
26  if (!poNumber) {
27    return ctx.clarify('Which purchase order number would you like to check?')
28  }
29
30  const [header, items] = await Promise.all([
31    ctx.callApi({
32      destination: 'S4HANA_CLOUD',
33      path: '/sap/opu/odata4/sap/api_purchaseorder_2/srvd_a2x/sap/api_purchaseorder_2/0001/A_PurchaseOrder',
34      params: {
35        '$filter': `PurchaseOrder eq '${poNumber}'`,
36        '$select': 'PurchaseOrder,Supplier,PurchaseOrderDate,PurchaseOrderStatus,TotalNetOrderAmount,TransactionCurrency',
37      },
38    }),
39    ctx.callApi({
40      destination: 'S4HANA_CLOUD',
41      path: '/sap/opu/odata4/sap/api_purchaseorder_2/srvd_a2x/sap/api_purchaseorder_2/0001/A_PurchaseOrderItem',
42      params: {
43        '$filter': `PurchaseOrder eq '${poNumber}'`,
44        '$select': 'PurchaseOrderItem,Material,PurchaseOrderItemText,OrderQuantity,OpenQuantity,DeliveryDate,GoodsReceiptStatus',
45        '$top': '10',
46      },
47    }),
48  ])
49
50  if (!header.value?.length) {
51    return ctx.respond({ type: 'message', text: `Purchase order ${poNumber} was not found.` })
52  }
53
54  const po = header.value[0]
55
56  // Store last-mentioned PO in session context for follow-ups
57  ctx.setSessionContext({ lastMentionedPO: poNumber })
58
59  return ctx.respond({
60    type: 'card',
61    title: `PO ${poNumber}`,
62    subtitle: `Supplier: ${po.Supplier} · Status: ${po.PurchaseOrderStatus}`,
63    properties: [
64      { label: 'Date', value: po.PurchaseOrderDate },
65      { label: 'Net Value', value: `${po.TotalNetOrderAmount} ${po.TransactionCurrency}` },
66    ],
67    table: {
68      columns: ['Item', 'Material', 'Description', 'Ordered', 'Open', 'Delivery', 'GR'],
69      rows: items.value.map(i => [
70        i.PurchaseOrderItem, i.Material, i.PurchaseOrderItemText,
71        i.OrderQuantity, i.OpenQuantity, i.DeliveryDate, i.GoodsReceiptStatus,
72      ]),
73    },
74    actions: [
75      { label: 'Open in Fiori', deepLink: `/sap/bc/ui2/flp#PurchaseOrder-displayFactSheet?PurchaseOrder=${poNumber}` },
76    ],
77  })
78}
79
80// ── Guided PR Creation (multi-step slot filling) ──────────────────────────────
81async function createPurchaseRequisition(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
82  const { material, quantity, plant, deliveryDate } = intent.entities
83
84  if (!material) return ctx.clarify('What material or service do you need to requisition?')
85  if (!quantity)  return ctx.clarify(`How many units of ${material} do you need?`)
86  if (!plant)     return ctx.clarify('Which plant or location should it be delivered to?')
87
88  return ctx.confirm({
89    title: 'Confirm Purchase Requisition',
90    message: `Create a PR for ${quantity} units of ${material} at plant ${plant}${deliveryDate ? ', delivery: ' + deliveryDate : ''}?`,
91    onConfirm: async () => {
92      const result = await ctx.callApi({
93        destination: 'S4HANA_CLOUD',
94        method: 'POST',
95        path: '/sap/opu/odata4/sap/api_purchaserequisition_2/srvd_a2x/sap/api_purchaserequisition_2/0001/A_PurReqn',
96        body: {
97          PurchaseRequisitionType: 'NB',
98          PurchaseRequisitionItem: [{
99            AccountAssignmentCategory: 'K',
100            Material: material,
101            RequestedQuantity: String(quantity),
102            Plant: plant,
103            DeliveryDate: deliveryDate ?? '',
104            PurReqnItemText: `Requested via SAP Joule by ${ctx.user.displayName}`,
105          }],
106        },
107      })
108
109      return ctx.respond({
110        type: 'card',
111        title: 'Purchase Requisition Created',
112        properties: [
113          { label: 'PR Number', value: result.value?.[0]?.PurchaseRequisition, highlight: true },
114          { label: 'Material', value: material },
115          { label: 'Quantity', value: String(quantity) },
116          { label: 'Plant', value: plant },
117          { label: 'Status', value: 'Awaiting Approval' },
118        ],
119        actions: [
120          { label: 'Track PR', deepLink: `/sap/bc/ui2/flp#PurchaseRequisition-manage` },
121        ],
122      })
123    },
124    onCancel: async () => ctx.respond({ type: 'message', text: 'Purchase requisition cancelled.' }),
125  })
126}
127
128async function vendorPaymentStatus(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
129  const vendor = intent.entities.vendorNumber
130  if (!vendor) return ctx.clarify('Which vendor number or name would you like to check?')
131
132  const result = await ctx.callApi({
133    destination: 'S4HANA_CLOUD',
134    path: '/sap/opu/odata4/sap/api_supplier_invoice_2/srvd_a2x/sap/api_supplier_invoice/0001/A_SupplierInvoice',
135    params: {
136      '$filter': `Supplier eq '${vendor}' and InvoiceStatus ne 'PAID'`,
137      '$select': 'SupplierInvoice,InvoiceDate,InvoiceGrossAmount,DocumentCurrency,PaymentDueDate,InvoiceStatus',
138      '$top': '10',
139      '$orderby': 'PaymentDueDate asc',
140    },
141  })
142
143  const invoices = result.value ?? []
144  if (!invoices.length) return ctx.respond({ type: 'message', text: `No open invoices found for vendor ${vendor}.` })
145
146  return ctx.respond({
147    type: 'list',
148    title: `Open Invoices — Vendor ${vendor}`,
149    items: invoices.map(inv => ({
150      title: `Invoice ${inv.SupplierInvoice}`,
151      properties: [
152        { label: 'Amount', value: `${inv.InvoiceGrossAmount} ${inv.DocumentCurrency}` },
153        { label: 'Due', value: inv.PaymentDueDate, highlight: new Date(inv.PaymentDueDate) < new Date() },
154        { label: 'Status', value: inv.InvoiceStatus },
155      ],
156    })),
157  })
158}

IT Service Assistant

Generally Available

IT Service Management — Integration Suite iFlow + Knowledge Base (RAG)

createIncidentcheckTicketStatussearchKnowledge
Backend: ITSM via Integration Suite + Knowledge Source (IT Policy PDFs)

My laptop screen is frozen, please raise a ticket

createIncident

Description extracted; urgency defaults to MEDIUM; confirm gate

What is the status of ticket INC-2025-001?

checkTicketStatus

ticketId extracted; returns status + resolution note

Any open tickets for me?

checkTicketStatus

No ticketId → returns recent open tickets for ctx.user.email

How do I connect to VPN from home?

searchKnowledge

RAG search → IT KB → LLM-synthesised answer with source citation

it-service-assistant.skill.ts
1// Joule Studio — IT Service Assistant skill handler
2// Intents: createIncident, checkTicketStatus, searchKnowledge
3
4import type { JouleIntent, JouleContext, SkillResponse } from '@sap/joule-skill-sdk'
5
6export const descriptor = {
7  skillId: 'com.company.it.service-assistant',
8  version: '1.0.0',
9  intents: ['createIncident', 'checkTicketStatus', 'searchKnowledge'],
10  // Knowledge source linked: IT Knowledge Base (PDF policies, wiki pages)
11  knowledgeSources: ['it-knowledge-base'],
12}
13
14export async function execute(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
15  switch (intent.id) {
16    case 'createIncident':   return createIncident(intent, ctx)
17    case 'checkTicketStatus': return checkTicketStatus(intent, ctx)
18    case 'searchKnowledge':  return searchKnowledgeBase(intent, ctx)
19    default: return ctx.respond({ type: 'message', text: 'I can raise incidents, check ticket status, or search the IT knowledge base.' })
20  }
21}
22
23// ── Create IT incident (calls Integration Suite iFlow → ITSM system) ─────────
24async function createIncident(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
25  const { issueDescription, urgency = 'MEDIUM', category = 'GENERAL' } = intent.entities
26
27  if (!issueDescription) {
28    return ctx.clarify('Please describe the IT issue you are experiencing.')
29  }
30
31  return ctx.confirm({
32    title: 'Raise IT Incident',
33    message: `Create an incident: "${issueDescription}" with urgency ${urgency}?`,
34    onConfirm: async () => {
35      // Route through SAP Integration Suite iFlow to the ITSM system
36      const result = await ctx.callApi({
37        destination: 'ITSM_INTEGRATION',
38        method: 'POST',
39        path: '/api/v1/incidents',
40        body: {
41          reportedBy: ctx.user.email,
42          displayName: ctx.user.displayName,
43          department: ctx.user.attributes?.department,
44          urgency,
45          category,
46          shortDescription: issueDescription,
47          source: 'SAP Joule',
48        },
49      })
50
51      return ctx.respond({
52        type: 'card',
53        title: 'Incident Raised',
54        properties: [
55          { label: 'Ticket ID', value: result.ticketId, highlight: true },
56          { label: 'Urgency', value: urgency },
57          { label: 'Status', value: 'Open — Assigned to IT Support' },
58          { label: 'Expected Response', value: urgency === 'HIGH' ? '< 2 hours' : '< 8 hours' },
59        ],
60        actions: [
61          { label: 'Track Ticket', deepLink: `/itsm/ticket/${result.ticketId}` },
62        ],
63      })
64    },
65    onCancel: async () => ctx.respond({ type: 'message', text: 'Incident not raised.' }),
66  })
67}
68
69// ── Check ticket status ───────────────────────────────────────────────────────
70async function checkTicketStatus(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
71  const ticketId = intent.entities.ticketId
72    ?? ctx.sessionContext?.lastMentionedTicket
73
74  if (!ticketId) {
75    // Fall back to recent open tickets for this user
76    const result = await ctx.callApi({
77      destination: 'ITSM_INTEGRATION',
78      path: '/api/v1/incidents',
79      params: { reportedBy: ctx.user.email, status: 'OPEN', '$top': '5' },
80    })
81    const tickets = result.items ?? []
82    if (!tickets.length) return ctx.respond({ type: 'message', text: 'You have no open IT tickets.' })
83
84    return ctx.respond({
85      type: 'list',
86      title: 'Your Open IT Tickets',
87      items: tickets.map((t: { ticketId: string; shortDescription: string; urgency: string; status: string; lastUpdated: string }) => ({
88        title: `${t.ticketId}${t.shortDescription}`,
89        properties: [
90          { label: 'Urgency', value: t.urgency },
91          { label: 'Status', value: t.status },
92          { label: 'Last Updated', value: t.lastUpdated },
93        ],
94      })),
95    })
96  }
97
98  const ticket = await ctx.callApi({
99    destination: 'ITSM_INTEGRATION',
100    path: `/api/v1/incidents/${ticketId}`,
101  })
102
103  ctx.setSessionContext({ lastMentionedTicket: ticketId })
104
105  return ctx.respond({
106    type: 'card',
107    title: `Ticket ${ticketId}`,
108    properties: [
109      { label: 'Description', value: ticket.shortDescription },
110      { label: 'Status', value: ticket.status, highlight: ticket.status === 'PENDING_USER' },
111      { label: 'Assigned To', value: ticket.assignedTo ?? 'IT Support Queue' },
112      { label: 'Last Updated', value: ticket.lastUpdated },
113      { label: 'Resolution Note', value: ticket.resolutionNote ?? 'Pending' },
114    ],
115    actions: [{ label: 'Open Ticket', deepLink: `/itsm/ticket/${ticketId}` }],
116  })
117}
118
119// ── Search IT knowledge base (RAG-grounded via Joule Studio Knowledge Source) ─
120async function searchKnowledgeBase(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
121  const query = intent.entities.searchQuery ?? intent.rawUtterance
122
123  // ctx.searchKnowledge() performs vector search against the linked knowledge source
124  // Returns chunks from indexed documents with relevance scores
125  const results = await ctx.searchKnowledge({
126    sourceId: 'it-knowledge-base',
127    query,
128    topK: 3,
129    minScore: 0.75,
130  })
131
132  if (!results.length) {
133    return ctx.respond({
134      type: 'message',
135      text: `No relevant articles found for "${query}". Would you like to raise an IT incident instead?`,
136      actions: [{ label: 'Raise Incident', intent: 'createIncident' }],
137    })
138  }
139
140  // Use Generative AI Hub to synthesise an answer from retrieved chunks
141  const answer = await ctx.generateResponse({
142    systemPrompt: 'You are an IT support assistant. Answer the user question using only the provided knowledge base articles. If the articles do not contain a clear answer, say so.',
143    userMessage: query,
144    context: results.map(r => r.content).join('\n\n'),
145    maxTokens: 300,
146  })
147
148  return ctx.respond({
149    type: 'message',
150    text: answer,
151    sources: results.map(r => ({ title: r.documentTitle, url: r.sourceUrl })),
152    actions: [{ label: 'Still need help? Raise Incident', intent: 'createIncident' }],
153  })
154}

Project Status Assistant

Generally Available

SAP S/4HANA Project System — WBS, Milestones, Budget, Resources

projectMilestonesresourceAllocationbudgetStatusprojectRisks
Backend: S/4HANA OData V4 — A_WBSElement, A_WBSMilestone, A_ProjectBudget, A_ProjectResource

Show milestones for project P-2025-001

projectMilestones

Fetches WBS + milestones in parallel; highlights overdue

What is the budget status of that project?

budgetStatus

Session context: lastProjectId from prior turn

Who is allocated to this project?

resourceAllocation

App context: currentRecord.projectId if Fiori app open

Are there any open risks on P-2025-001?

projectRisks

Returns open risks ordered by impact; HIGH highlighted

project-status-assistant.skill.ts
1// Joule Studio — Project Status Assistant skill handler
2// Intents: projectMilestones, resourceAllocation, budgetStatus, projectRisks
3
4import type { JouleIntent, JouleContext, SkillResponse } from '@sap/joule-skill-sdk'
5
6export const descriptor = {
7  skillId: 'com.company.pmo.project-assistant',
8  version: '1.1.0',
9  intents: ['projectMilestones', 'resourceAllocation', 'budgetStatus', 'projectRisks'],
10}
11
12export async function execute(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
13  switch (intent.id) {
14    case 'projectMilestones':   return projectMilestones(intent, ctx)
15    case 'resourceAllocation':  return resourceAllocation(intent, ctx)
16    case 'budgetStatus':        return budgetStatus(intent, ctx)
17    case 'projectRisks':        return projectRisks(intent, ctx)
18    default: return ctx.respond({ type: 'message', text: 'I can show project milestones, resource allocation, budget status, or risks.' })
19  }
20}
21
22async function projectMilestones(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
23  const projectId = intent.entities.projectId
24    ?? ctx.appContext?.currentRecord?.projectId  // pick up from open Fiori app
25    ?? ctx.sessionContext?.lastProjectId
26
27  if (!projectId) return ctx.clarify('Which project ID or name would you like to check?')
28  ctx.setSessionContext({ lastProjectId: projectId })
29
30  // SAP Collaborative Project Experience (CPEx) OData V4
31  const [project, milestones] = await Promise.all([
32    ctx.callApi({
33      destination: 'CPEX_S4HANA',
34      path: `/sap/opu/odata4/sap/api_wbselement_2/srvd_a2x/sap/api_wbselement_2/0001/A_WBSElement`,
35      params: {
36        '$filter': `ProjectInternalID eq '${projectId}'`,
37        '$select': 'WBSElement,WBSElementName,ProjectStatus,PlannedStartDate,PlannedEndDate',
38        '$top': '1',
39      },
40    }),
41    ctx.callApi({
42      destination: 'CPEX_S4HANA',
43      path: `/sap/opu/odata4/sap/api_wbselement_2/srvd_a2x/sap/api_wbselement_2/0001/A_WBSMilestone`,
44      params: {
45        '$filter': `ProjectInternalID eq '${projectId}'`,
46        '$select': 'Milestone,MilestoneName,PlannedDate,ActualDate,IsMilestoneAchieved',
47        '$orderby': 'PlannedDate asc',
48      },
49    }),
50  ])
51
52  const proj = project.value?.[0]
53  if (!proj) return ctx.respond({ type: 'message', text: `Project ${projectId} not found or you do not have access.` })
54
55  const overdue = milestones.value?.filter(
56    (m: { IsMilestoneAchieved: boolean; PlannedDate: string }) => !m.IsMilestoneAchieved && new Date(m.PlannedDate) < new Date()
57  ) ?? []
58
59  return ctx.respond({
60    type: 'card',
61    title: `Project: ${proj.WBSElementName}`,
62    subtitle: `Status: ${proj.ProjectStatus}${overdue.length ? ` · ${overdue.length} overdue milestones` : ''}`,
63    properties: [
64      { label: 'Planned Start', value: proj.PlannedStartDate },
65      { label: 'Planned End', value: proj.PlannedEndDate },
66    ],
67    table: {
68      columns: ['Milestone', 'Planned Date', 'Achieved', 'Actual Date'],
69      rows: (milestones.value ?? []).map((m: { MilestoneName: string; PlannedDate: string; IsMilestoneAchieved: boolean; ActualDate: string }) => [
70        m.MilestoneName,
71        m.PlannedDate,
72        m.IsMilestoneAchieved ? 'Yes' : 'Overdue',
73        m.ActualDate ?? '—',
74      ]),
75    },
76    actions: [
77      { label: 'Open Project', deepLink: `/sap/bc/ui2/flp#ProjectManagement-open?ProjectID=${projectId}` },
78    ],
79  })
80}
81
82async function budgetStatus(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
83  const projectId = intent.entities.projectId ?? ctx.sessionContext?.lastProjectId
84  if (!projectId) return ctx.clarify('Which project would you like the budget status for?')
85
86  const result = await ctx.callApi({
87    destination: 'S4HANA_CLOUD',
88    path: '/sap/opu/odata4/sap/api_project_budget_2/srvd_a2x/sap/api_project_budget_2/0001/A_ProjectBudget',
89    params: {
90      '$filter': `ProjectInternalID eq '${projectId}'`,
91      '$select': 'ProjectInternalID,BudgetCurrency,OriginalBudget,CurrentBudget,ActualCosts,CommittedCosts,RemainingBudget',
92    },
93  })
94
95  const budget = result.value?.[0]
96  if (!budget) return ctx.respond({ type: 'message', text: 'Budget data not available for this project.' })
97
98  const utilisation = Math.round(
99    ((parseFloat(budget.ActualCosts) + parseFloat(budget.CommittedCosts)) / parseFloat(budget.CurrentBudget)) * 100
100  )
101
102  return ctx.respond({
103    type: 'card',
104    title: `Project Budget — ${projectId}`,
105    properties: [
106      { label: 'Current Budget', value: `${budget.CurrentBudget} ${budget.BudgetCurrency}` },
107      { label: 'Actual Costs', value: `${budget.ActualCosts} ${budget.BudgetCurrency}` },
108      { label: 'Committed', value: `${budget.CommittedCosts} ${budget.BudgetCurrency}` },
109      { label: 'Remaining', value: `${budget.RemainingBudget} ${budget.BudgetCurrency}`, highlight: parseFloat(budget.RemainingBudget) < 0 },
110      { label: 'Utilisation', value: `${utilisation}%`, highlight: utilisation > 90 },
111    ],
112  })
113}
114
115async function resourceAllocation(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
116  const projectId = intent.entities.projectId ?? ctx.sessionContext?.lastProjectId
117  if (!projectId) return ctx.clarify('Which project would you like resource allocation for?')
118
119  const result = await ctx.callApi({
120    destination: 'S4HANA_CLOUD',
121    path: '/sap/opu/odata4/sap/api_projectresource_2/srvd_a2x/sap/api_projectresource_2/0001/A_ProjectResource',
122    params: {
123      '$filter': `ProjectInternalID eq '${projectId}'`,
124      '$select': 'PersonnelNumber,EmployeeName,WorkPackage,PlannedHours,ActualHours,AllocationPercentage',
125    },
126  })
127
128  const resources = result.value ?? []
129  return ctx.respond({
130    type: 'list',
131    title: `Resource Allocation — Project ${projectId}`,
132    items: resources.map((r: { EmployeeName: string; WorkPackage: string; PlannedHours: number; ActualHours: number; AllocationPercentage: number }) => ({
133      title: r.EmployeeName,
134      properties: [
135        { label: 'Work Package', value: r.WorkPackage },
136        { label: 'Planned', value: `${r.PlannedHours}h` },
137        { label: 'Actual', value: `${r.ActualHours}h` },
138        { label: 'Allocation', value: `${r.AllocationPercentage}%`, highlight: r.AllocationPercentage > 100 },
139      ],
140    })),
141  })
142}
143
144async function projectRisks(intent: JouleIntent, ctx: JouleContext): Promise<SkillResponse> {
145  const projectId = intent.entities.projectId ?? ctx.sessionContext?.lastProjectId
146  if (!projectId) return ctx.clarify('Which project risks would you like to see?')
147
148  const result = await ctx.callApi({
149    destination: 'CPEX_S4HANA',
150    path: '/sap/opu/odata4/sap/api_projectrisk_2/srvd_a2x/sap/api_projectrisk_2/0001/A_ProjectRisk',
151    params: {
152      '$filter': `ProjectInternalID eq '${projectId}' and RiskStatus ne 'CLOSED'`,
153      '$select': 'RiskID,RiskTitle,RiskImpact,RiskProbability,RiskOwner,RiskStatus,MitigationPlan',
154      '$orderby': 'RiskImpact desc',
155    },
156  })
157
158  const risks = result.value ?? []
159  if (!risks.length) return ctx.respond({ type: 'message', text: `No open risks found for project ${projectId}.` })
160
161  return ctx.respond({
162    type: 'list',
163    title: `Open Risks — Project ${projectId}`,
164    items: risks.map((r: { RiskTitle: string; RiskImpact: string; RiskProbability: string; RiskOwner: string; RiskStatus: string; MitigationPlan: string }) => ({
165      title: r.RiskTitle,
166      properties: [
167        { label: 'Impact', value: r.RiskImpact, highlight: r.RiskImpact === 'HIGH' },
168        { label: 'Probability', value: r.RiskProbability },
169        { label: 'Owner', value: r.RiskOwner },
170        { label: 'Mitigation', value: r.MitigationPlan ?? 'Not defined' },
171      ],
172    })),
173  })
174}

Licensing & Prerequisites

Status:Generally AvailablePlannedRoadmapFuture Direction

Joule Studio — Prerequisites and Capability Access

Component / Feature
Required?Mandatory for Joule Studio
Included InDefault entitlement
NotesLicensing detail
Platform Prerequisites
SAP BTP Global AccountYesRISE with SAP / standaloneJoule Studio runs on BTP — no BTP = no Joule Studio
SAP Joule subscriptionYesRISE with SAP (Booster)Core Joule service subscription required before authoring custom skills
SAP Build Code subscriptionYesSAP Build SuiteJoule Studio is accessed via SAP Build Code service plan
SAP Cloud Identity Services (IAS/IPS)YesRISE with SAPShared IAS tenant mandatory for user context propagation
Custom Skill Authoring
Joule Studio intent editorYesBuild Code subscriptionDefine intents, entities, sample utterances
SAP Build Code IDE (BAS Dev Space)YesBuild Code subscriptionTypeScript skill handler development with Joule AI assist
Test Console (NLU + response testing)YesBuild Code subscriptionInteractive testing before skill publication
Skill Catalogue publishingYesJoule Booster entitlementDeploy skill to organisation Joule tenant catalogue
Knowledge Sources (RAG)
Knowledge Source document indexingNoJoule Platform (check current tier)Upload and index documents for RAG-grounded skill responses
SAP AI Core (vector embedding)NoAI Core subscriptionRequired for knowledge source vector index storage
Generative AI Hub (LLM)NoAI Core subscriptionRequired for knowledge-grounded response generation and summarisation
Joule Studio entitlement — verify with SAP
Joule Studio feature availability evolves with each SAP Build Code and Joule platform release. Always verify current entitlements against the SAP Discovery Center and your SAP Joule service subscription terms. The Joule Booster entitlement for RISE customers includes Joule Studio access — confirm with your SAP account executive if it is not automatically visible in your BTP cockpit.

Road Map & Recent Updates

Source: SAP Road Map Explorer (roadmaps.sap.com) and SAP Sapphire / TechEd 2025 announcements.

Status:Generally AvailablePlannedRoadmapFuture Direction
Generally Available

Generally Available

  • Intent and entity slot definition in Joule Studio
  • TypeScript skill handler (Joule Skill SDK)
  • ctx.callApi() with OData V4 and REST
  • Principal propagation via IAS/XSUAA
  • Slot-filling (ctx.clarify())
  • Confirmation gate (ctx.confirm())
  • Session and user context (ctx.sessionContext, ctx.user)
  • Test Console (interactive NLU testing)
  • Skill version management and catalogue publishing
Planned

Planned

  • Enhanced Test Console with automated regression run
  • Skill performance dashboard (intent confidence, error rate)
  • Additional response card types and layout options
  • Improved NLU training with larger utterance sets
  • Simplified knowledge source management UI
Roadmap

Roadmap

  • Visual agent flow designer (drag-and-drop conversation graph)
  • Multi-skill orchestration — chain skills across domains
  • AI-generated skill handler from intent description
  • Live skill debugging with breakpoints in Test Console
  • Skill marketplace — share and discover community skills
Future Direction

Future Direction

  • Autonomous agent mode — Joule acts across multiple steps unsupervised
  • Cross-system skill actions (one skill spans S/4HANA + SF + Ariba)
  • Joule learns from user corrections to improve intent accuracy
  • Self-healing skills — auto-update OData paths after API version changes

Best Practices

Write 8–12 diverse sample utterances per intent

The NLU engine learns from variety. Include formal phrasing ("Show me the status of"), casual phrasing ("what's happening with"), question forms, and command forms. Avoid near-duplicate utterances that differ only in a single word.

Always use ctx.confirm() before any write operation

Never execute POST, PATCH, or DELETE actions without a confirmation step. Joule users can easily misphrase commands — a confirmation card prevents accidental data creation or modification and builds user trust.

Use $select and $top on every OData call

Never fetch all fields of all records. Specify exactly the fields you need in $select and limit with $top. Joule panel responses are designed for concise summaries — large payloads slow response time and degrade UX.

Store entity values in session context for follow-ups

After resolving a PO number, project ID, or ticket ID, call ctx.setSessionContext() to store it. This enables natural follow-up questions ("what about that project's budget?") without the user repeating the identifier.

Leverage app context before prompting for IDs

Check ctx.appContext.currentRecord before asking the user for an entity ID. If the user has a Purchase Order open in Fiori, ctx.appContext.currentRecord.PurchaseOrder likely has the answer — avoid a redundant clarification.

Test authorisation failures — not just happy paths

Skills must handle 403 Forbidden gracefully (user lacks ABAP authorisation). Always add a test case for data the test user should not see. Return a clear message: "You do not have access to this record" rather than an unhandled error.

Common Pitfalls

Calling unreleased SAP APIs

Using internal BAPIs, RFCs, or undocumented OData services in skill handlers will break on S/4HANA upgrade. Always verify the API status is "Released" on the SAP API Business Hub before using it in a skill.

Missing confirmation before write

A skill that creates a purchase requisition or books leave without a ctx.confirm() step will execute on the first utterance — with no way for the user to abort. Every write path must pass through a confirmation gate.

Too few sample utterances

An intent with only 2–3 sample utterances will have poor NLU accuracy. Users will phrase the same intent differently every time. Provide at least 8 diverse examples and include common misspellings or abbreviations.

Not handling empty API results

Skills that call .value[0] without checking for an empty array will throw a runtime error. Always check if the result set is empty and return a friendly "No records found" message rather than crashing.

Knowledge source not re-indexed after document update

When a policy PDF or handbook is updated, the knowledge source must be re-indexed. Old chunks from the previous version will remain in the vector store until re-indexed. Stale policies in Joule responses can cause compliance issues.

Skill overlapping with SAP-delivered skills

If a custom skill covers the same intent as an SAP-delivered skill (e.g., "show my leave balance"), the Skill Router may route to the wrong skill based on confidence scores. Use distinct intent names and utterances that do not overlap with the default catalogue.

SAP References