diff --git a/docs/docs/module_guides/workflow/index.md b/docs/docs/module_guides/workflow/index.md
index facefee27f70a6024766e442b5c608a6eebadad7..34d01d0d4bfac29c63285a312f091af08b6aabb5 100644
--- a/docs/docs/module_guides/workflow/index.md
+++ b/docs/docs/module_guides/workflow/index.md
@@ -110,11 +110,17 @@ class JokeFlow(Workflow):
     ...
 ```
 
-Here, we come to the entry-point of our workflow. While events are use-defined, there are two special-case events, the `StartEvent` and the `StopEvent`. Here, the `StartEvent` signifies where to send the initial workflow input.
+Here, we come to the entry-point of our workflow. While most events are use-defined, there are two special-case events,
+the `StartEvent` and the `StopEvent` that the framework provides out of the box. Here, the `StartEvent` signifies where
+to send the initial workflow input.
 
-The `StartEvent` is a bit of a special object since it can hold arbitrary attributes. Here, we accessed the topic with `ev.topic`, which would raise an error if it wasn't there. You could also do `ev.get("topic")` to handle the case where the attribute might not be there without raising an error.
+The `StartEvent` is a bit of a special object since it can hold arbitrary attributes. Here, we accessed the topic with
+`ev.topic`, which would raise an error if it wasn't there. You could also do `ev.get("topic")` to handle the case where
+the attribute might not be there without raising an error.
 
-At this point, you may have noticed that we haven't explicitly told the workflow what events are handled by which steps. Instead, the `@step` decorator is used to infer the input and output types of each step. Furthermore, these inferred input and output types are also used to verify for you that the workflow is valid before running!
+At this point, you may have noticed that we haven't explicitly told the workflow what events are handled by which steps.
+Instead, the `@step` decorator is used to infer the input and output types of each step. Furthermore, these inferred
+input and output types are also used to verify for you that the workflow is valid before running!
 
 ### Workflow Exit Points
 
@@ -133,7 +139,9 @@ class JokeFlow(Workflow):
     ...
 ```
 
-Here, we have our second, and last step, in the workflow. We know its the last step because the special `StopEvent` is returned. When the workflow encounters a returned `StopEvent`, it immediately stops the workflow and returns whatever the result was.
+Here, we have our second, and last step, in the workflow. We know its the last step because the special `StopEvent` is
+returned. When the workflow encounters a returned `StopEvent`, it immediately stops the workflow and returns whatever
+we passed in the `result` parameter.
 
 In this case, the result is a string, but it could be a dictionary, list, or any other object.
 
@@ -145,9 +153,127 @@ result = await w.run(topic="pirates")
 print(str(result))
 ```
 
-Lastly, we create and run the workflow. There are some settings like timeouts (in seconds) and verbosity to help with debugging.
+Lastly, we create and run the workflow. There are some settings like timeouts (in seconds) and verbosity to help with
+debugging.
 
-The `.run()` method is async, so we use await here to wait for the result.
+The `.run()` method is async, so we use await here to wait for the result. The keyword arguments passed to `run()` will
+become fields of the special `StartEvent` that will be automatically emitted and start the workflow. As we have seen,
+in this case `topic` will be accessed from the step with `ev.topic`.
+
+## Customizing entry and exit points
+
+Most of the times, relying on the default entry and exit points we have seen in the [Getting Started] section is enough.
+However, workflows support custom events where you normally would expect `StartEvent` and `StopEvent`, let's see how.
+
+### Using a custom `StartEvent`
+
+When we call the `run()` method on a workflow instance, the keyword arguments passed become fields of a `StartEvent`
+instance that's automatically created under the hood. In case we want to pass complex data to start a workflow, this
+approach might become cumbersome, and it's when we can introduce a custom start event.
+
+To be able to use a custom start event, the first step is creating a custom class that inherits from `StartEvent`:
+
+```python
+from pathlib import Path
+
+from llama_index.core.workflow import StartEvent
+from llama_index.indices.managed.llama_cloud import LlamaCloudIndex
+from llama_index.llms.openai import OpenAI
+
+
+class MyCustomStartEvent(StartEvent):
+    a_string_field: str
+    a_path_to_somewhere: Path
+    an_index: LlamaCloudIndex
+    an_llm: OpenAI
+```
+
+All we have to do now is using `MyCustomStartEvent` as event type in the steps that act as entry points.
+Take this artificially complex step for example:
+
+```python
+class JokeFlow(Workflow):
+    ...
+
+    @step
+    async def generate_joke_from_index(
+        self, ev: MyCustomStartEvent
+    ) -> JokeEvent:
+        # Build a query engine using the index and the llm from the start event
+        query_engine = ev.an_index.as_query_engine(llm=ev.an_llm)
+        topic = query_engine.query(
+            f"What is the closest topic to {a_string_field}"
+        )
+        # Use the llm attached to the start event to instruct the model
+        prompt = f"Write your best joke about {topic}."
+        response = await ev.an_llm.acomplete(prompt)
+        # Dump the response on disk using the Path object from the event
+        ev.a_path_to_somewhere.write_text(str(response))
+        # Finally, pass the JokeEvent along
+        return JokeEvent(joke=str(response))
+```
+
+We could still pass the fields of `MyCustomStartEvent` as keyword arguments to the `run` method of our workflow, but
+that would be, again, cumbersome. A better approach is to use pass the event instance through the `start_event`
+keyword argument like this:
+
+```python
+custom_start_event = MyCustomStartEvent(...)
+w = JokeFlow(timeout=60, verbose=False)
+result = await w.run(start_event=custom_start_event)
+print(str(result))
+```
+
+This approach makes the code cleaner and more explicit and allows autocompletion in IDEs to work properly.
+
+### Using a custom `StopEvent`
+
+Similarly to `StartEvent`, relying on the built-in `StopEvent` works most of the times but not always. In fact, when we
+use `StopEvent`, the result of a workflow must be set to the `result` field of the event instance. Since a result can
+be any Python object, the `result` field of `StopEvent` is typed as `Any`, losing any advantage from the typing system.
+Additionally, returning more than one object is cumbersome: we usually stuff a bunch of unrelated objects into a
+dictionary that we then assign to `StopEvent.result`.
+
+First step to support custom stop events, we need to create a subclass of `StopEvent`:
+
+```python
+from llama_index.core.workflow import StopEvent
+
+
+class MyStopEvent(StopEvent):
+    critique: CompletionResponse
+```
+
+We can now replace `StopEvent` with `MyStopEvent` in our workflow:
+
+```python
+class JokeFlow(Workflow):
+    ...
+
+    @step
+    async def critique_joke(self, ev: JokeEvent) -> MyStopEvent:
+        joke = ev.joke
+
+        prompt = f"Give a thorough analysis and critique of the following joke: {joke}"
+        response = await self.llm.acomplete(prompt)
+        return MyStopEvent(response)
+
+    ...
+```
+
+The one important thing we need to remember when using a custom stop events, is that the result of a workflow run
+will be the instance of the event:
+
+```python
+w = JokeFlow(timeout=60, verbose=False)
+# Warning! `result` now contains an instance of MyStopEvent!
+result = await w.run(topic="pirates")
+# We can now access the event fields as any normal Event
+print(result.critique.text)
+```
+
+This approach takes advantage of the Python typing system, is friendly to autocompletion in IDEs and allows
+introspection from outer applications that now know exactly what a workflow run will return.
 
 ## Drawing the Workflow