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.
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
20:15Joule Studio — Build Your First Skill
14:55Joule Studio — Knowledge Source Config
25:08Joule Studio — Action Integration
Joule Studio Architecture
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).
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.
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).
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.
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.
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.
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.
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.
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.
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
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 AvailableSAP SuccessFactors — Time Management
checkLeaveBalancebookLeavecheckLeaveStatus“How much annual leave do I have?”
checkLeaveBalanceNo entities needed — returns all balances
“Book me leave next Monday to Wednesday”
bookLeavestartDate + endDate extracted; confirm gate before POST
“I want to take Friday off”
bookLeaveSingle-day leave; endDate defaults to startDate
“Has my leave been approved?”
checkLeaveStatusReturns most recent pending requests
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 AvailableSAP S/4HANA — Purchasing (MM)
purchaseOrderStatusvendorPaymentStatuscreatePurchaseRequisition“Show me PO 4500012345”
purchaseOrderStatusPO number extracted; parallel header + item fetch
“What items are open on that PO?”
purchaseOrderStatusSession context: lastMentionedPO resolves "that PO"
“Open invoices for vendor 100?”
vendorPaymentStatusReturns overdue invoices highlighted in red
“Create a PR for 50 units of FG100 at plant 1000”
createPurchaseRequisitionMulti-entity; confirm gate; OData V4 POST
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 AvailableIT Service Management — Integration Suite iFlow + Knowledge Base (RAG)
createIncidentcheckTicketStatussearchKnowledge“My laptop screen is frozen, please raise a ticket”
createIncidentDescription extracted; urgency defaults to MEDIUM; confirm gate
“What is the status of ticket INC-2025-001?”
checkTicketStatusticketId extracted; returns status + resolution note
“Any open tickets for me?”
checkTicketStatusNo ticketId → returns recent open tickets for ctx.user.email
“How do I connect to VPN from home?”
searchKnowledgeRAG search → IT KB → LLM-synthesised answer with source citation
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 AvailableSAP S/4HANA Project System — WBS, Milestones, Budget, Resources
projectMilestonesresourceAllocationbudgetStatusprojectRisks“Show milestones for project P-2025-001”
projectMilestonesFetches WBS + milestones in parallel; highlights overdue
“What is the budget status of that project?”
budgetStatusSession context: lastProjectId from prior turn
“Who is allocated to this project?”
resourceAllocationApp context: currentRecord.projectId if Fiori app open
“Are there any open risks on P-2025-001?”
projectRisksReturns open risks ordered by impact; HIGH highlighted
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
Joule Studio — Prerequisites and Capability Access
Component / Feature | Required?Mandatory for Joule Studio | Included InDefault entitlement | NotesLicensing detail |
|---|---|---|---|
| Platform Prerequisites | |||
| SAP BTP Global Account | Yes | RISE with SAP / standalone | Joule Studio runs on BTP — no BTP = no Joule Studio |
| SAP Joule subscription | Yes | RISE with SAP (Booster) | Core Joule service subscription required before authoring custom skills |
| SAP Build Code subscription | Yes | SAP Build Suite | Joule Studio is accessed via SAP Build Code service plan |
| SAP Cloud Identity Services (IAS/IPS) | Yes | RISE with SAP | Shared IAS tenant mandatory for user context propagation |
| Custom Skill Authoring | |||
| Joule Studio intent editor | Yes | Build Code subscription | Define intents, entities, sample utterances |
| SAP Build Code IDE (BAS Dev Space) | Yes | Build Code subscription | TypeScript skill handler development with Joule AI assist |
| Test Console (NLU + response testing) | Yes | Build Code subscription | Interactive testing before skill publication |
| Skill Catalogue publishing | Yes | Joule Booster entitlement | Deploy skill to organisation Joule tenant catalogue |
| Knowledge Sources (RAG) | |||
| Knowledge Source document indexing | No | Joule Platform (check current tier) | Upload and index documents for RAG-grounded skill responses |
| SAP AI Core (vector embedding) | No | AI Core subscription | Required for knowledge source vector index storage |
| Generative AI Hub (LLM) | No | AI Core subscription | Required for knowledge-grounded response generation and summarisation |
Road Map & Recent Updates
Source: SAP Road Map Explorer (roadmaps.sap.com) and SAP Sapphire / TechEd 2025 announcements.
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
- 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
- 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
- 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
Official documentation: intent editor, entity configuration, skill handler SDK, test console, publishing.
The IDE that hosts Joule Studio — AI-assisted development, SAP BAS Dev Spaces, Joule for developers.
npm package for the Joule Skill SDK — ctx.callApi(), ctx.respond(), ctx.confirm(), ctx.clarify().
Browse and verify the release status of SAP OData V4 APIs that custom skills can consume.
SAP Road Map Explorer: Joule Studio feature roadmap, planned releases, and GA milestones.
Identity propagation setup — mandatory for ctx.user context and principal propagation to backends.